@lotics/ui 46.0.2 → 46.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -177,8 +177,15 @@ PUT and rolls the calls out BELOW it on press.
177
177
 
178
178
  **ONE row for the whole run, and NO prose until it ends.** While the agent works, the entire feed
179
179
  is a single row. It names the call in flight — "Searching records" — and says
180
- **"Thinking"** whenever the model is writing rather than calling, which is every gap between
181
- calls. The dot pulses; nothing rolls out unless the reader asks.
180
+ **"Thinking"** whenever the model is writing rather than calling, which is every gap between
181
+ calls. Nothing rolls out unless the reader asks.
182
+
183
+ **An unsettled row BREATHES**, label and dot together — the label on the same opacity rhythm the
184
+ dot's halo runs (750ms each way), so one line carries one animation rather than two that drift
185
+ apart. That breath is what says "in progress", which is why no live label ends in an ellipsis:
186
+ punctuation was standing in for motion, and a screen reader drops it anyway. A row that is not
187
+ working does not breathe — settled, and `awaiting` a human decision, which is parked rather than
188
+ in flight.
182
189
 
183
190
  **No step count.** The row says WHAT is happening and stops there. A tally was a number the reader
184
191
  could act on in no way: live, the label already changes on every call, so it added no motion the
@@ -208,12 +215,14 @@ reasoning part. An interleaved-thinking run therefore rebuilt the stack one grou
208
215
  reader watched finished rows pile up while they waited, and was then left with that pile sitting
209
216
  above the answer at the answer's weight, every row of it stale.
210
217
 
211
- **Expanding is where live and settled differ, and the only place they do.** Opened mid-run the
212
- timeline is CAPPED and self-pinning (220px, ~5 rows) someone who opens a run in flight asked to
213
- see the work, not to hand the page a feed that grows for another minute; `FollowScroll` follows at
214
- LAYOUT level so each call paints already pinned. Opened once settled it is uncapped: nothing is
215
- arriving, so there is nothing to cap. Settling never closes what a reader opened; it just stops
216
- capping it.
218
+ **Expanding works the same live and settled** the timeline opens IN FULL, and the HOST'S OWN
219
+ scroller keeps the newest row in view. `AgentRun` never frames itself. It shipped once with a
220
+ live-only 220px window, and that window was a scroll container nested inside whatever scroll
221
+ container the run already sat in: in chat, an inverted list inside an inverted list, which painted
222
+ stale content over the neighbouring messages and took the wheel from the surface the reader was
223
+ on. A component in document flow cannot know whether it is inside a scroller, so it must not act
224
+ as one. A host that genuinely needs a bound takes the framing job itself — `collapseProcess={false}`
225
+ plus its own `FollowScroll`. Settling never closes what a reader opened.
217
226
 
218
227
  The split is POSITIONAL (`splitTimeline` in `agent_transform`): what comes after the last tool
219
228
  call is the answer, everything before it is the work. Nothing has to decide whether a paragraph
@@ -261,10 +270,10 @@ tinted panel rather than another step row, and it carries `role="alert"` so a sc
261
270
  action (a secondary `Button`) sits INSIDE that panel so the operator can re-fire the run; omit it
262
271
  and the panel carries the message alone.
263
272
 
264
- **`FollowScroll` — only when you took the job (`collapseProcess={false}`).** A run opened mid-flight
265
- already caps itself with exactly this, so wrapping the default AGAIN nests a scroller in a
266
- scroller: the outer never overflows, the inner holds the content, and the wheel lands on whichever
267
- the pointer happens to be over. Reach for the wrapper when the run is a raw timeline you are
273
+ **`FollowScroll` — only when you took the job (`collapseProcess={false}`).** On the default, the
274
+ run is plain content in your flow and your own scroller follows it; adding a wrapper there nests a
275
+ scroller in a scroller — the outer never overflows, the inner holds the content, and the wheel
276
+ lands on whichever the pointer happens to be over. Reach for the wrapper when the run is a raw timeline you are
268
277
  framing yourself — a dialog, drawer, or fixed-height panel where the feed grows BELOW the fold and
269
278
  a plain scroll container doesn't follow. Wrap it then:
270
279
  `<FollowScroll style={{ maxHeight: … }}><AgentRun … collapseProcess={false} /></FollowScroll>` — the same
@@ -281,7 +290,7 @@ for free — no hand-assembly): `<AgentRun parts={run.parts} state={…} error={
281
290
  [the SDK doc](../../app-sdk/docs/ai.md) for the hook. The `ai` package is a regular dependency of
282
291
  `@lotics/ui` — its part TYPES resolve transitively in any consumer's typecheck with nothing to
283
292
  install; the imports are type-only (purity-enforced), so no `ai` runtime ever enters a bundle.
284
- **A run with no parts yet renders the breathing "Starting" row** (streaming state only) — the
293
+ **A run with no parts yet renders the breathing "Starting" row** (streaming state only) — the
285
294
  live dot plus a label that pulses, covering the gap between the CTA press and the first streamed
286
295
  part (upload + run creation + first token). **The law: pass
287
296
  `state="streaming"` from the moment the paid CTA fires — uploads included — and render
@@ -518,7 +527,7 @@ whole pane with no backend ([`tpl_item_list`](../examples/tpl_item_list.tsx) doe
518
527
 
519
528
  **Customize through the seams, or drop to the primitives.** `labelForCall` / `renderToolOutput`
520
529
  pass through to `AgentRun`; the `run` object is the data seam. There is deliberately NO
521
- empty-state slot — `AgentRun` renders its own localized "Starting" row on zero parts, and the law
530
+ empty-state slot — `AgentRun` renders its own localized "Starting" row on zero parts, and the law
522
531
  above forbids hand-rolling a placeholder. An app that wants a different ARRANGEMENT does not fight
523
532
  the pane: `AgentRun`, `ClarifyWizard`, `ClarifyWizardScope`/`Actions`, `FollowScroll` and
524
533
  `DialogScrollArea` all remain exported, and composing them is what this pane itself does.
package/docs/catalog.md CHANGED
@@ -2094,13 +2094,15 @@ component rather than showing it at zero.
2094
2094
  hatch — input/error untouched). A per-tool failure shows amber with the reason in its expanded
2095
2095
  Error panel; a run-level breaking `error` (outside `parts`) renders as a terminal danger row, with
2096
2096
  an optional `onRetry` Button under it. While it runs the whole feed is ONE row and NO prose: the
2097
- row names the call in flight ("Searching records") and says "Thinking" whenever the
2098
- model is writing rather than calling. The agent's between-call narration is never shown the one
2097
+ row names the call in flight ("Searching records") and says "Thinking" whenever the
2098
+ model is writing rather than calling; an unsettled row BREATHES, label and dot on one rhythm,
2099
+ which is why no live label carries an ellipsis (a row that is parked `awaiting` does not breathe). The agent's between-call narration is never shown — the one
2099
2100
  message needing a response is a bulk-mutation confirmation, and the agent waits there, so the run
2100
2101
  stops and that text becomes the answer. A reply with no tool calls is all answer and still streams.
2101
- At settle the work folds to "{last action}" with the answer below. Expanding is the one
2102
- place live and settled differ: opened mid-run the timeline is capped and self-pinning (220px),
2103
- opened settled it is uncapped. Stays fully open while a call is parked, when a SETTLED run ended on
2102
+ At settle the work folds to "{last action}" with the answer below. Expanding opens the timeline
2103
+ IN FULL, live or settled the run never frames itself, and the HOST'S scroller follows it (a
2104
+ self-imposed window nests a scroller inside the host's own, which in chat means an inverted list
2105
+ inside an inverted list). Stays fully open while a call is parked, when a SETTLED run ended on
2104
2106
  a tool call, and when the work is already one row. `collapseProcess={false}` hands the job back to
2105
2107
  the caller (a surface that already frames the run — it then owns the `FollowScroll` too);
2106
2108
  `summarizeRun(steps)` renames the row.
@@ -2134,7 +2136,8 @@ component rather than showing it at zero.
2134
2136
  the chat message list's inverted mechanism packaged as a wrapper (single-cell inverted list),
2135
2137
  so the follow happens at LAYOUT level: each growth paints already pinned (no flash), scrolling
2136
2138
  up releases it, and short content still reads top-down. Wrap a streaming `AgentRun` with it in
2137
- any bounded container (a dialog, a drawer, a panel — put the height bound on `style`);
2139
+ any bounded container (a dialog, a drawer, a panel — put the height bound on `style`) — but ONLY
2140
+ one you framed yourself with `collapseProcess={false}`, or it nests a scroller in a scroller;
2138
2141
  `AgentProgress`'s expanded panel uses it. Inherits the inverted pattern's quirks (scrollbar
2139
2142
  thumb runs opposite; keyboard paging inverted) — same as chat. A full-page chat list is
2140
2143
  already inverted and doesn't need it.
@@ -2231,7 +2234,7 @@ component rather than showing it at zero.
2231
2234
  or test DECLARES one rather than mapping from `ClarifyWizardQuestion` (an option is a label +
2232
2235
  description; the answer's value IS the label, the `ask_user_choice` wire shape). Seams:
2233
2236
  `labelForCall` / `renderToolOutput`; no empty-state slot by design (`AgentRun` owns the
2234
- localized "Starting" row). Worked example: `tpl_item_list`'s intake dialog.
2237
+ localized "Starting" row). Worked example: `tpl_item_list`'s intake dialog.
2235
2238
  - **`choice_list`** — `ChoiceList` + `ChoiceOption`: selectable answer options as
2236
2239
  divider-separated rows (no bordered cards) with a per-row focus ring + hover wash; the
2237
2240
  agent's quick-reply surface. `allowCustom` appends an always-visible borderless multiline field whose
package/docs/templates.md CHANGED
@@ -87,6 +87,20 @@ intended behaviour when the job calls for one — the line between a choice and
87
87
  [composition.md](./composition.md) §"The form comes before the treatment", and it is drawn there
88
88
  for this too.
89
89
 
90
+ **A surface that is a FILTERED VIEW of another surface is not a separate subject.** The same test
91
+ that separates a tab from a lens decides whether a screen should exist at all, one altitude up: if
92
+ its rows are another screen's rows with a condition applied, it is a lens on that screen and belongs
93
+ in its toolbar. The tell is a screen that reads as disconnected while every part of it looks
94
+ correct — a costs screen whose rows are the bank ledger filtered to outflows, an "overdue" screen
95
+ whose rows are the register filtered by a date. Each one duplicates a subject and then drifts from
96
+ it, because two surfaces over one set of rows grow two ways of writing to them.
97
+
98
+ **And nothing should answer a question another surface already owns.** A register that starts
99
+ computing the P&L is building a second answer to a question the dashboard is for — the two will
100
+ disagree the first time either changes, and the reader has no way to know which is right. Where a
101
+ screen genuinely needs a figure that lives elsewhere, it states the figure and points; it does not
102
+ re-derive it.
103
+
90
104
  ### App-screen shapes
91
105
 
92
106
  **What earns a tab.** A band is a TAB when it changes what the screen IS — its columns, the act on
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "46.0.2",
3
+ "version": "46.2.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
package/src/agent_run.tsx CHANGED
@@ -8,7 +8,6 @@ import { Callout, CalloutActions, CalloutText } from "./callout";
8
8
  import { Markdown } from "./markdown";
9
9
  import { JsonPanel, stringifyData } from "./json_panel";
10
10
  import { PressableHighlight } from "./pressable_highlight";
11
- import { FollowScroll } from "./follow_scroll";
12
11
  import { Marker, type StepStatus } from "./stepper";
13
12
  import { NODE } from "./stepper_layout";
14
13
  import { AnimationFadeIn } from "./animation_fade_in";
@@ -86,7 +85,7 @@ export interface AgentRunProps {
86
85
  // ── Tool → display meta ───────────────────────────────────────────────────────
87
86
  // The ICON per platform tool. The LABEL lives in the locale (`agentRun.tools`)
88
87
  // because it is language, and this component already renders localized chrome
89
- // around these rows — an English tool row under a Vietnamese "Đang suy nghĩ…"
88
+ // around these rows — an English tool row under a Vietnamese "Đang suy nghĩ"
90
89
  // was the kit contradicting itself. An icon is not language, so it stays here.
91
90
  // A tool neither map knows falls back to a prettified name + a neutral icon.
92
91
  const TOOL_ICONS: Record<string, IconName> = {
@@ -149,6 +148,37 @@ function stepLabel(
149
148
 
150
149
  const INK = colors.zinc[700];
151
150
 
151
+ // The "still working" breath, and the ONE rhythm every unsettled row shares.
152
+ // Half-cycle 750ms — so a full breath is the 1500ms the `Marker`'s live halo
153
+ // already runs, and a row's dot and its label breathe together instead of
154
+ // drifting apart into two unrelated animations on one line. The floor is
155
+ // `Skeleton`'s: the kit says "in progress" with an opacity rhythm, in one
156
+ // vocabulary, whether the thing pulsing is a placeholder tile or a live label.
157
+ const BREATH_HALF = 750;
158
+ const BREATH_FLOOR = 0.6;
159
+
160
+ /** Breathe `children` while the work behind them is unsettled. Settled, it
161
+ * holds full opacity and runs no animation — a finished row is not working,
162
+ * and neither is one parked `awaiting` a human decision. */
163
+ function Breathing({ active, children }: { active: boolean; children: ReactNode }) {
164
+ const v = useRef(new Animated.Value(1)).current;
165
+ useEffect(() => {
166
+ if (!active) {
167
+ v.setValue(1);
168
+ return;
169
+ }
170
+ const loop = Animated.loop(
171
+ Animated.sequence([
172
+ Animated.timing(v, { toValue: BREATH_FLOOR, duration: BREATH_HALF, useNativeDriver: true }),
173
+ Animated.timing(v, { toValue: 1, duration: BREATH_HALF, useNativeDriver: true }),
174
+ ]),
175
+ );
176
+ loop.start();
177
+ return () => loop.stop();
178
+ }, [active, v]);
179
+ return <Animated.View style={{ opacity: v }}>{children}</Animated.View>;
180
+ }
181
+
152
182
  /**
153
183
  * A live feed of an AI agent's work as a TIMELINE — the prose it streams and the
154
184
  * tools it calls, in the order they happened. While the agent is mid-tools the
@@ -197,9 +227,9 @@ export function AgentRun(props: AgentRunProps) {
197
227
  // answer, and hiding THAT behind a row until settle would be the real loss.
198
228
  //
199
229
  // SETTLED, the collapse covers the work and the answer stands below it.
200
- // Expanding differs between the two, and only there: LIVE the timeline is
201
- // capped and self-pinning (opening a run mid-flight must not hand the page a
202
- // feed that grows for another minute), SETTLED it opens in full.
230
+ // Expanding is the same either way the timeline opens IN FULL, and what
231
+ // keeps the newest row in view is the host's own scroller, which every host
232
+ // of a live run already has.
203
233
  //
204
234
  // The settled clauses are cases where collapsing would hide something needed:
205
235
  // · no answer — the run stopped ON a tool call, so the fold would leave an
@@ -211,7 +241,7 @@ export function AgentRun(props: AgentRunProps) {
211
241
  const folded = managed && (streaming ? process.length > 0 : process.length > 1 && steps.length > 0 && result.length > 0);
212
242
 
213
243
  // What the row SAYS right now: the tool in flight, or — when the model is
214
- // writing rather than calling — "Thinking". Naming the last finished action
244
+ // writing rather than calling — "Thinking". Naming the last finished action
215
245
  // while the agent composes prose reports a moment that has passed.
216
246
  const thinking = streaming && !lastRunningStep(segments);
217
247
 
@@ -415,29 +445,19 @@ function StepRow({ s, label, renderToolOutput }: { s: AgentStep; label: string;
415
445
 
416
446
  // Thinking — a muted, COLLAPSED "Thinking" disclosure (revealed on press), so the
417
447
  // agent's reasoning never crowds the answer. Pulses while it's still streaming.
418
- /** The empty-live feed row: the live dot's halo plus a BREATHING label — the
419
- * message itself pulses (Skeleton's opacity rhythm), so "work has started"
420
- * reads at a glance even before the first token. */
448
+ /** The empty-live feed row: the live dot's halo plus a BREATHING label, so
449
+ * "work has started" reads at a glance even before the first token. */
421
450
  function StartingRow({ label }: { label: string }) {
422
- const v = useRef(new Animated.Value(0.45)).current;
423
- useEffect(() => {
424
- const loop = Animated.loop(
425
- Animated.sequence([
426
- Animated.timing(v, { toValue: 1, duration: 700, useNativeDriver: false }),
427
- Animated.timing(v, { toValue: 0.45, duration: 700, useNativeDriver: false }),
428
- ]),
429
- );
430
- loop.start();
431
- return () => loop.stop();
432
- }, [v]);
433
451
  return (
434
452
  <View style={styles.row}>
435
453
  <View style={styles.dotCol}>
436
454
  <Marker status="current" color={colors.zinc[400]} live />
437
455
  </View>
438
- <Animated.View style={[styles.rowBody, { opacity: v }]}>
439
- <Text size="sm" color="muted">{label}</Text>
440
- </Animated.View>
456
+ <View style={styles.rowBody}>
457
+ <Breathing active>
458
+ <Text size="sm" color="muted">{label}</Text>
459
+ </Breathing>
460
+ </View>
441
461
  </View>
442
462
  );
443
463
  }
@@ -452,9 +472,11 @@ function ReasoningDisclosure(props: { text: string; streaming?: boolean; expande
452
472
  <Marker status={streaming ? "current" : "done"} color={colors.zinc[400]} live={!!streaming} />
453
473
  </View>
454
474
  <View style={styles.rowBody}>
455
- <Text size="sm" color="muted">
456
- {streaming ? locale.agentRun.thinkingStreaming : locale.agentRun.thinking}
457
- </Text>
475
+ <Breathing active={!!streaming}>
476
+ <Text size="sm" color="muted">
477
+ {streaming ? locale.agentRun.thinkingStreaming : locale.agentRun.thinking}
478
+ </Text>
479
+ </Breathing>
458
480
  </View>
459
481
  {chevron(expanded ? "up" : "down")}
460
482
  </PressableHighlight>
@@ -514,7 +536,7 @@ function WorkSummary(props: {
514
536
  const { steps, live, thinking, expanded, onToggle, labelForCall, summarizeRun, children } = props;
515
537
  const locale = useLoticsLocale();
516
538
  const final = steps[steps.length - 1];
517
- // "Thinking" WINS over a host summary while the model writes: `summarizeRun`
539
+ // "Thinking" WINS over a host summary while the model writes: `summarizeRun`
518
540
  // names what the run DID, and mid-compose that is a moment already past.
519
541
  const label = thinking
520
542
  ? locale.agentRun.thinkingStreaming
@@ -537,32 +559,30 @@ function WorkSummary(props: {
537
559
  trailing={chevron(expanded ? "up" : "down")}
538
560
  >
539
561
  {/* Keyed by what the row is SAYING, so a new call (or the switch to
540
- "Thinking") rises + fades into the SAME row — the label swaps, the
562
+ "Thinking") rises + fades into the SAME row — the label swaps, the
541
563
  row does not move. Exactly what a settled `ToolGroup` does mid-burst,
542
- held for the whole run. */}
543
- <AnimationFadeIn key={live ? label : "settled"} translateY={4}>
544
- <SummaryLabel label={label} />
545
- </AnimationFadeIn>
564
+ held for the whole run.
565
+ The breath sits OUTSIDE that key: inside it, the rhythm would restart
566
+ from full opacity on every label swap and fight the fade's own — two
567
+ animations writing one opacity. Outside, the row breathes steadily
568
+ for the whole run while its label changes underneath. */}
569
+ <Breathing active={live}>
570
+ <AnimationFadeIn key={live ? label : "settled"} translateY={4}>
571
+ <SummaryLabel label={label} />
572
+ </AnimationFadeIn>
573
+ </Breathing>
546
574
  </ActivityRow>
547
575
  {/* The run's own 10px rhythm, not the group's 2px: what rolls out here is
548
576
  whole segments — prose, thinking, tool groups — not sibling step rows.
549
- Opened while the run is still LIVE it is also CAPPED and self-pinning:
550
- a reader who opens a run mid-flight asked to see the work, not to hand
551
- the page a feed that grows for another minute. `FollowScroll` follows at
552
- LAYOUT level (an inverted single-cell list), so each new call paints
553
- already pinned no scroll-after-paint flash. Settled, it opens in full:
554
- nothing is arriving, so there is nothing to cap. */}
555
- {expanded ? (
556
- live ? (
557
- <View testID="agent-run-work-capped" style={styles.work}>
558
- <FollowScroll style={styles.liveFrame}>
559
- <View style={{ gap: 10 }}>{children}</View>
560
- </FollowScroll>
561
- </View>
562
- ) : (
563
- <View testID="agent-run-work" style={styles.work}>{children}</View>
564
- )
565
- ) : null}
577
+ It opens IN FULL, live or settled, and the HOST owns the scrolling. A
578
+ self-imposed window here was a scroller nested inside whatever scroller
579
+ the run already sits in in chat, an inverted list inside an inverted
580
+ list which paints stale content over its neighbours and captures the
581
+ wheel from the surface the reader is actually on. A component in
582
+ document flow cannot know whether it is inside a scroll container, so
583
+ it must not act as though it were one; a host that needs a bound frames
584
+ the run itself (`collapseProcess={false}` + `FollowScroll`). */}
585
+ {expanded ? <View testID="agent-run-work" style={styles.work}>{children}</View> : null}
566
586
  </View>
567
587
  );
568
588
  }
@@ -597,9 +617,11 @@ function ToolGroup(props: {
597
617
  return (
598
618
  <View style={styles.group}>
599
619
  <ActivityRow markerStatus="current" live>
600
- <AnimationFadeIn key={current.id} translateY={4}>
601
- <StepBody label={resolve(current)} />
602
- </AnimationFadeIn>
620
+ <Breathing active>
621
+ <AnimationFadeIn key={current.id} translateY={4}>
622
+ <StepBody label={resolve(current)} />
623
+ </AnimationFadeIn>
624
+ </Breathing>
603
625
  </ActivityRow>
604
626
  </View>
605
627
  );
@@ -654,12 +676,6 @@ const styles = StyleSheet.create({
654
676
  // The folded work, opened: segment spacing (the run's own gap), and a small
655
677
  // lead-in under the header so the rolled-out work reads as its content.
656
678
  work: { gap: 10, paddingTop: 6 },
657
- // The cap on work OPENED mid-run. ~5 rows: enough to read what is happening,
658
- // short enough that opening a p90 94s run does not push the composer off the
659
- // screen for the rest of it. No border and no background — the run sits flush
660
- // in the consumer's gutter by law, and a frame that appeared only while
661
- // streaming would be the loudest thing on the surface.
662
- liveFrame: { maxHeight: 220 },
663
679
  row: {
664
680
  flexDirection: "row",
665
681
  alignItems: "center",
@@ -174,14 +174,16 @@ export function AgentRunPane<TLanding>(props: AgentRunPaneProps<TLanding>) {
174
174
  // 24` here is a fifth copy of a number that has to agree with four others.
175
175
  <FollowScroll contentContainerStyle={{ paddingBottom: 24, paddingHorizontal: gutter }}>
176
176
  {/* No empty-state slot on purpose. `AgentRun` renders its own breathing
177
- "Starting" row while streaming with zero parts, localized through the
177
+ "Starting" row while streaming with zero parts, localized through the
178
178
  `agentRun` locale slice — and ai_patterns states the law outright:
179
179
  never hand-roll a placeholder in front of the feed. A slot here would
180
180
  invite exactly that, and every app would localize it again. */}
181
- {/* collapseProcess=false: this pane took the framing job the FollowScroll
182
- above IS the bound, sized by the dialog. Leaving the default on would
183
- nest the run's own 220px window inside it, and the dialog's scroller
184
- would then never overflow while the inner one held every step. */}
181
+ {/* collapseProcess=false: the run IS this pane's subject. A dialog opened
182
+ to watch one run, that then folds it behind a row the reader has to
183
+ press, hides the only thing it exists to show the fold is for a run
184
+ sharing a surface with other content (a chat thread), not for one that
185
+ owns the surface. The FollowScroll above is the bound, sized by the
186
+ dialog; taking the framing job is what earns the right to add one. */}
185
187
  <AgentRun
186
188
  parts={run.parts}
187
189
  state={run.status === "error" ? "error" : run.status === "streaming" ? "streaming" : "done"}
@@ -6,8 +6,19 @@ import { Svg, Path } from "react-native-svg";
6
6
  * takes a generic `Icon` — a phone call, an in-person event, a website, an
7
7
  * email that is not tied to one provider. Inventing a mark for those is how a
8
8
  * byline ends up claiming a channel the record does not carry.
9
+ *
10
+ * A CHANNEL earns a mark; a PRODUCT does not. The line is whether a business
11
+ * reaches people through it — a post, a message, a campaign — or merely uses it
12
+ * to work. So the ad platforms belong here beside the social surfaces, while a
13
+ * mailbox or a drive does not, and the two are easy to confuse because the same
14
+ * company often sells both.
15
+ *
16
+ * Every mark is a single-path CC0 silhouette taken from simple-icons, with that
17
+ * project's own hex. Never hand-write one: a recalled path renders something
18
+ * plausible and slightly wrong, which is the one defect a reader spots instantly
19
+ * and a test suite never does.
9
20
  */
10
- export const BRAND_NAMES = ["facebook", "zalo", "linkedin", "x", "google-meet"] as const;
21
+ export const BRAND_NAMES = ["facebook", "meta", "tiktok", "google-ads", "zalo", "linkedin", "x", "google-meet"] as const;
11
22
 
12
23
  export type BrandName = (typeof BRAND_NAMES)[number];
13
24
 
@@ -39,6 +50,25 @@ interface Mark {
39
50
  * in (`SwitchButton`'s `leading` slot).
40
51
  */
41
52
  const MARKS: Record<BrandName, Mark> = {
53
+ /** The company and its ad platform. `facebook` is the consumer surface a post goes
54
+ * on; `meta` is what bills the ad spend, and a row about money names the
55
+ * platform that took it. */
56
+ "meta": {
57
+ color: "#0467DF",
58
+ path: "M6.915 4.03c-1.968 0-3.683 1.28-4.871 3.113C.704 9.208 0 11.883 0 14.449c0 .706.07 1.369.21 1.973a6.624 6.624 0 0 0 .265.86 5.297 5.297 0 0 0 .371.761c.696 1.159 1.818 1.927 3.593 1.927 1.497 0 2.633-.671 3.965-2.444.76-1.012 1.144-1.626 2.663-4.32l.756-1.339.186-.325c.061.1.121.196.183.3l2.152 3.595c.724 1.21 1.665 2.556 2.47 3.314 1.046.987 1.992 1.22 3.06 1.22 1.075 0 1.876-.355 2.455-.843a3.743 3.743 0 0 0 .81-.973c.542-.939.861-2.127.861-3.745 0-2.72-.681-5.357-2.084-7.45-1.282-1.912-2.957-2.93-4.716-2.93-1.047 0-2.088.467-3.053 1.308-.652.57-1.257 1.29-1.82 2.05-.69-.875-1.335-1.547-1.958-2.056-1.182-.966-2.315-1.303-3.454-1.303zm10.16 2.053c1.147 0 2.188.758 2.992 1.999 1.132 1.748 1.647 4.195 1.647 6.4 0 1.548-.368 2.9-1.839 2.9-.58 0-1.027-.23-1.664-1.004-.496-.601-1.343-1.878-2.832-4.358l-.617-1.028a44.908 44.908 0 0 0-1.255-1.98c.07-.109.141-.224.211-.327 1.12-1.667 2.118-2.602 3.358-2.602zm-10.201.553c1.265 0 2.058.791 2.675 1.446.307.327.737.871 1.234 1.579l-1.02 1.566c-.757 1.163-1.882 3.017-2.837 4.338-1.191 1.649-1.81 1.817-2.486 1.817-.524 0-1.038-.237-1.383-.794-.263-.426-.464-1.13-.464-2.046 0-2.221.63-4.535 1.66-6.088.454-.687.964-1.226 1.533-1.533a2.264 2.264 0 0 1 1.088-.285z",
59
+ },
60
+ /** Short video, and for a Vietnamese seller increasingly the channel that outsells
61
+ * the one they budget for. */
62
+ "tiktok": {
63
+ color: "#000000",
64
+ path: "M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z",
65
+ },
66
+ /** Search and shopping ads. Distinct from `google-meet`, which is a meeting rather
67
+ * than a spend. */
68
+ "google-ads": {
69
+ color: "#4285F4",
70
+ path: "M3.9998 22.9291C1.7908 22.9291 0 21.1383 0 18.9293s1.7908-3.9998 3.9998-3.9998 3.9998 1.7908 3.9998 3.9998-1.7908 3.9998-3.9998 3.9998zm19.4643-6.0004L15.4632 3.072C14.3586 1.1587 11.9121.5028 9.9988 1.6074S7.4295 5.1585 8.5341 7.0718l8.0009 13.8567c1.1046 1.9133 3.5511 2.5679 5.4644 1.4646 1.9134-1.1046 2.568-3.5511 1.4647-5.4644zM7.5137 4.8438L1.5645 15.1484A4.5 4.5 0 0 1 4 14.4297c2.5597-.0075 4.6248 2.1585 4.4941 4.7148l3.2168-5.5723-3.6094-6.25c-.4499-.7793-.6322-1.6394-.5878-2.4784z",
71
+ },
42
72
  facebook: {
43
73
  color: "#0866FF",
44
74
  path: "M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z",
package/src/locale.tsx CHANGED
@@ -460,9 +460,9 @@ export const en: LoticsLocale = {
460
460
  scrollToBottom: { tooltip: "Scroll to bottom" },
461
461
  textInputField: { clear: "Clear" },
462
462
  agentRun: {
463
- starting: "Starting",
463
+ starting: "Starting",
464
464
  thinking: "Thinking",
465
- thinkingStreaming: "Thinking",
465
+ thinkingStreaming: "Thinking",
466
466
  input: "Input",
467
467
  error: "Error",
468
468
  output: "Output",
@@ -667,9 +667,9 @@ export const vi: LoticsLocale = {
667
667
  scrollToBottom: { tooltip: "Cuộn xuống cuối" },
668
668
  textInputField: { clear: "Xóa" },
669
669
  agentRun: {
670
- starting: "Đang bắt đầu",
670
+ starting: "Đang bắt đầu",
671
671
  thinking: "Suy nghĩ",
672
- thinkingStreaming: "Đang suy nghĩ…",
672
+ thinkingStreaming: "Đang suy nghĩ",
673
673
  input: "Đầu vào",
674
674
  error: "Lỗi",
675
675
  output: "Kết quả",