@atelic-action/ui 0.6.1 → 0.7.1
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.
- package/README.md +4 -2
- package/package.json +1 -1
- package/src/email/index.ts +11 -0
- package/src/email/scoreboard.tsx +178 -29
- package/src/email/text.ts +61 -4
- package/src/tokens/palettes.ts +6 -0
package/README.md
CHANGED
|
@@ -162,6 +162,8 @@ Runner emails are mobile first. A layout that squashes on a phone is fixed here,
|
|
|
162
162
|
|
|
163
163
|
`RecordStack` takes `records`, each a `RecordStackItem` (`title`, `url`, `meta`, `note`, `badge`, `callout`), and renders one table row per record: the title on its own line at 15px, bold and wrapping freely, linked when `url` is set; a mono meta line beneath it with the items joined by middle dots (empty items drop); an optional note beneath that; a hairline between records and none after the last. No cell carries a width, so a name of any length wraps at 320 pixels instead of squashing. An optional `badge` (a stage word such as `"Contacted"`) sits on the title line in a small mono uppercase pill on a `line` hairline, in its own right aligned unbroken cell, so the title keeps wrapping beside it and the badge stays on the title's first line. An optional `callout` (`{ eyebrow, text }`) sets a short task under the meta and the note in the accented `Note` idiom: the eyebrow in the accent, the text at 13px, and a 2px accent rule down its left edge. A record with neither renders exactly as it did before. `recordStackText(records)` is its plain text twin: each title on its own line under a two space indent with any badge after it in square brackets (`Blue Heron Plumbing [Contacted]`), the meta and the note wrapped under a four space indent, any callout as its eyebrow in upper case over its text under that same indent, a blank line between records, and no line past `textWidth` (68 columns, indent included).
|
|
164
164
|
|
|
165
|
+
The Company Cards additions (0.7.0, from the `Company Cards IA` design, 2026-09-29) ride on the same item and change nothing for a record without them. `badges` is a list of further pills after `badge`, each with a `tone`: `faint` is the quiet stage word, `ink` a bold pill in ink on a 1.5px border (the next touch), `accent` the same in the accent (New). `timeline` (`{ days, touches, opens }`, each day count back from today) draws the record's recent days between the title and the meta line as one table cell per marked day and one per run of empty days between them (seven cells for three marks, never thirty, since Gmail clips a message past about 100 KB), an 8px ink square on each send day and a 5px accent dot on each known open day, the two stacked when they share a day, all standing on a `line` hairline with a 1.5px ink tick at today's edge; nothing is positioned, so it survives Gmail. `RecordTimelineStrip` renders one on its own and `RecordTimelineLegend` names the marks once above a list. `aside` (`{ text, strong, tone }`) sets a fact right on the meta line with its number in bold and colored by tone: `up` fresh, `warm` cooling, `accent` cold. In the text twin every pill follows the title in brackets, the timeline is one character per day (`10d [#······@·o|]`: `#` a send, `o` an open, `@` both, `·` nothing, `|` today), and the aside ends the meta line; `recordTimelineText` and `recordBadges` are the helpers it uses.
|
|
166
|
+
|
|
165
167
|
`StatStrip` lays its stats out as inline block cells with an 88 pixel floor inside one centered cell, so six or seven stats flow onto a second row on a phone rather than shrinking. Each stat takes an optional `delta` (`"+3 (12%)"`), set small under its label and colored by its sign: one that opens on `+` wears the palette's `up`, one that opens on `-` its `down`, and anything else stays muted.
|
|
166
168
|
|
|
167
169
|
`Masthead` takes an optional `meta` (`"Week 39 · 09/21 to 09/27"`) set in the eyebrow style at the right end of the row, so the title card no longer carries the week. It is a right aligned, unbroken block floated after the wordmark and title: on the same line while the row has room, and dropped under the title on its own line, still right aligned, when it does not (checked at 320 pixels). `Eyebrow` takes `strong`, which sets the label at 15px and weight 700 in ink rather than faint, with a slightly tighter tracking, so a section such as `MQL · 15` reads as a heading on a phone.
|
|
@@ -227,7 +229,7 @@ Where the jq takes a pre rendered html string (`$rows`, `$body_html`, `$cells_ht
|
|
|
227
229
|
| `badge` | `Badge` | `letter` |
|
|
228
230
|
| `day_strip` | `DayStrip` | `days`, `last` |
|
|
229
231
|
| `stat_strip` | `StatStrip` | `stats`, each with an optional `delta` colored by its sign (the component wraps where the jq does not) |
|
|
230
|
-
| none | `RecordStack` | `records`, each with an optional `badge` and `
|
|
232
|
+
| none | `RecordStack` | `records`, each with an optional `badge`, `callout`, and since 0.7.0 `badges`, `timeline` and `aside` (born here on 2026-09-24, with no jq counterpart) |
|
|
231
233
|
| `records` | `Records` | `columns`, `rows`; a cell's `html` is a `ReactNode` |
|
|
232
234
|
| `masthead` | `Masthead` | `title`, `wordmark` (defaults to `atelic`), `meta` (added 0.5.0, no jq counterpart) |
|
|
233
235
|
| `title_card` | `TitleCard` | `eyebrowText`, `headlineLines`, `lede`, `stats` (the rows under the lede, in place of the jq's `$stats_html`) |
|
|
@@ -243,7 +245,7 @@ The plain text alternative part ports as plain functions with no React anywhere
|
|
|
243
245
|
|
|
244
246
|
`@atelic-action/ui/tokens` carries the palette and the font stacks with no React import, so a build script or a plain text renderer can read them. `toThemeCSS(palette)` writes the palette out as the site token declarations (`--surface`, `--surface-dark`, `--card`, `--ink`, `--primary`), and `themeTokenMap` exposes which palette key each token takes.
|
|
245
247
|
|
|
246
|
-
The palette carries
|
|
248
|
+
The palette carries three optional keys beyond the jq's eight: `up` (`#2F7A4B`, a calm green) and `down` (`#B23A2E`, a calm red, kept well apart from the orange accent), the colors a `StatStrip` delta wears by its sign, and `warm` (`#B7791F`, an amber between `up` and the accent) for a record's aside that is cooling but not cold. All three read on the cream card. They are optional so a client palette written before 0.5.0 still compiles, and a palette without them borrows the Atelic values. None maps to a site token.
|
|
247
249
|
|
|
248
250
|
## Releasing
|
|
249
251
|
|
package/package.json
CHANGED
package/src/email/index.ts
CHANGED
|
@@ -62,6 +62,10 @@ export {
|
|
|
62
62
|
type RecordsCell,
|
|
63
63
|
type RecordsColumn,
|
|
64
64
|
type RecordsProps,
|
|
65
|
+
RecordTimelineLegend,
|
|
66
|
+
type RecordTimelineLegendProps,
|
|
67
|
+
RecordTimelineStrip,
|
|
68
|
+
type RecordTimelineStripProps,
|
|
65
69
|
Scoreboard,
|
|
66
70
|
type ScoreboardProps,
|
|
67
71
|
StatStrip,
|
|
@@ -79,8 +83,15 @@ export {
|
|
|
79
83
|
asciiDowncase,
|
|
80
84
|
asciiUpcase,
|
|
81
85
|
lpad,
|
|
86
|
+
type RecordAside,
|
|
87
|
+
type RecordAsideTone,
|
|
88
|
+
type RecordBadge,
|
|
89
|
+
type RecordBadgeTone,
|
|
82
90
|
type RecordStackItem,
|
|
91
|
+
type RecordTimeline,
|
|
92
|
+
recordBadges,
|
|
83
93
|
recordStackText,
|
|
94
|
+
recordTimelineText,
|
|
84
95
|
rpad,
|
|
85
96
|
spaces,
|
|
86
97
|
type TextTarget,
|
package/src/email/scoreboard.tsx
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { type CSSProperties, Fragment, type ReactNode } from "react";
|
|
2
2
|
import { atelicPalette, type Palette } from "../tokens";
|
|
3
|
-
import
|
|
3
|
+
import {
|
|
4
|
+
type RecordAsideTone,
|
|
5
|
+
type RecordBadgeTone,
|
|
6
|
+
type RecordStackItem,
|
|
7
|
+
type RecordTimeline,
|
|
8
|
+
recordBadges,
|
|
9
|
+
} from "./text";
|
|
4
10
|
import { eyebrowStyle, tableReset, useEmailTheme } from "./theme";
|
|
5
11
|
|
|
6
12
|
/*
|
|
@@ -410,14 +416,17 @@ export function StatStrip({ stats }: StatStripProps) {
|
|
|
410
416
|
);
|
|
411
417
|
}
|
|
412
418
|
|
|
413
|
-
type RecordBadgeProps = { text: string };
|
|
419
|
+
type RecordBadgeProps = { text: string; tone?: RecordBadgeTone };
|
|
414
420
|
|
|
415
421
|
/**
|
|
416
|
-
*
|
|
417
|
-
*
|
|
422
|
+
* The pill on a record's title line. Faint is the stage word as it always
|
|
423
|
+
* was, dim text in a hairline box; ink and accent are the Company Cards
|
|
424
|
+
* pills (2026-09-29), bold, drawn in their own color on a heavier border:
|
|
425
|
+
* the next touch in ink, New in the accent.
|
|
418
426
|
*/
|
|
419
|
-
function RecordBadge({ text }: RecordBadgeProps) {
|
|
427
|
+
function RecordBadge({ text, tone = "faint" }: RecordBadgeProps) {
|
|
420
428
|
const { palette, fonts } = useEmailTheme();
|
|
429
|
+
const color = tone === "ink" ? palette.ink : tone === "accent" ? palette.accent : palette.dim;
|
|
421
430
|
return (
|
|
422
431
|
<span
|
|
423
432
|
style={{
|
|
@@ -427,11 +436,11 @@ function RecordBadge({ text }: RecordBadgeProps) {
|
|
|
427
436
|
lineHeight: "14px",
|
|
428
437
|
letterSpacing: "0.08em",
|
|
429
438
|
textTransform: "uppercase",
|
|
439
|
+
fontWeight: tone === "faint" ? "normal" : "bold",
|
|
430
440
|
padding: "0 6px",
|
|
431
|
-
border: `1px solid ${palette.line}`,
|
|
441
|
+
border: tone === "faint" ? `1px solid ${palette.line}` : `1.5px solid ${color}`,
|
|
432
442
|
borderRadius: "3px",
|
|
433
|
-
color
|
|
434
|
-
whiteSpace: "nowrap",
|
|
443
|
+
color,
|
|
435
444
|
}}
|
|
436
445
|
>
|
|
437
446
|
{text}
|
|
@@ -440,13 +449,123 @@ function RecordBadge({ text }: RecordBadgeProps) {
|
|
|
440
449
|
}
|
|
441
450
|
|
|
442
451
|
export type { RecordStackItem };
|
|
452
|
+
|
|
453
|
+
/** The default for a palette written before `warm` existed: the cooling tone between `up` and the accent. */
|
|
454
|
+
const WARM = "#B7791F";
|
|
455
|
+
|
|
456
|
+
function asideColor(tone: RecordAsideTone | undefined, palette: Palette): string {
|
|
457
|
+
if (tone === "up") return palette.up ?? atelicPalette.up ?? palette.ink;
|
|
458
|
+
if (tone === "warm") return palette.warm ?? WARM;
|
|
459
|
+
if (tone === "accent") return palette.accent;
|
|
460
|
+
return palette.faint;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export type RecordTimelineStripProps = { timeline: RecordTimeline };
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* A record's recent days on one line, today at the right edge: an ink square
|
|
467
|
+
* on each day a send went out, an accent dot on each day an open is known,
|
|
468
|
+
* the two stacked when they share a day, all standing on a hairline with a
|
|
469
|
+
* tick for today. One cell per marked day and one per run of empty days
|
|
470
|
+
* between them, so a record with three marks is seven cells rather than
|
|
471
|
+
* thirty: Gmail clips a message past about 100 KB and a stage list carries
|
|
472
|
+
* dozens of records. Nothing is positioned, so it survives every mail client
|
|
473
|
+
* (Company Cards IA, 2026-09-29).
|
|
474
|
+
*/
|
|
475
|
+
export function RecordTimelineStrip({ timeline }: RecordTimelineStripProps) {
|
|
476
|
+
const { palette } = useEmailTheme();
|
|
477
|
+
const days = Math.max(1, Math.round(timeline.days));
|
|
478
|
+
const touch = new Set(timeline.touches.map((d) => Math.round(d)));
|
|
479
|
+
const open = new Set(timeline.opens.map((d) => Math.round(d)));
|
|
480
|
+
const cells: { ago: number; span: number; sent: boolean; opened: boolean }[] = [];
|
|
481
|
+
for (let ago = days - 1; ago >= 0; ago -= 1) {
|
|
482
|
+
const sent = touch.has(ago);
|
|
483
|
+
const opened = open.has(ago);
|
|
484
|
+
const last = cells[cells.length - 1];
|
|
485
|
+
if (!sent && !opened && last && !last.sent && !last.opened) last.span += 1;
|
|
486
|
+
else cells.push({ ago, span: 1, sent, opened });
|
|
487
|
+
}
|
|
488
|
+
const unit = 100 / days;
|
|
489
|
+
return (
|
|
490
|
+
<table
|
|
491
|
+
{...tableReset}
|
|
492
|
+
width="100%"
|
|
493
|
+
style={{
|
|
494
|
+
marginTop: "8px",
|
|
495
|
+
height: "14px",
|
|
496
|
+
textAlign: "center",
|
|
497
|
+
borderBottom: `1px solid ${palette.line}`,
|
|
498
|
+
borderRight: `1.5px solid ${palette.ink}`,
|
|
499
|
+
}}
|
|
500
|
+
>
|
|
501
|
+
<tbody>
|
|
502
|
+
<tr>
|
|
503
|
+
{cells.map((cell) => (
|
|
504
|
+
<td
|
|
505
|
+
key={cell.ago}
|
|
506
|
+
valign="bottom"
|
|
507
|
+
colSpan={cell.span}
|
|
508
|
+
width={`${Math.round(unit * cell.span * 100) / 100}%`}
|
|
509
|
+
>
|
|
510
|
+
{cell.sent ? (
|
|
511
|
+
<div
|
|
512
|
+
style={{ width: "8px", height: "8px", margin: "0 auto", background: palette.ink }}
|
|
513
|
+
/>
|
|
514
|
+
) : null}
|
|
515
|
+
{cell.opened ? (
|
|
516
|
+
<div
|
|
517
|
+
style={{
|
|
518
|
+
width: "5px",
|
|
519
|
+
height: "5px",
|
|
520
|
+
margin: `${cell.sent ? "2px" : "0"} auto 0`,
|
|
521
|
+
borderRadius: "50%",
|
|
522
|
+
background: palette.accent,
|
|
523
|
+
}}
|
|
524
|
+
/>
|
|
525
|
+
) : null}
|
|
526
|
+
</td>
|
|
527
|
+
))}
|
|
528
|
+
</tr>
|
|
529
|
+
</tbody>
|
|
530
|
+
</table>
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
export type RecordTimelineLegendProps = { days: number };
|
|
535
|
+
|
|
536
|
+
/** What the marks on a timeline are, once above a list of records: the square, the dot, and the span. */
|
|
537
|
+
export function RecordTimelineLegend({ days }: RecordTimelineLegendProps) {
|
|
538
|
+
const { palette, fonts } = useEmailTheme();
|
|
539
|
+
const mark = (style: CSSProperties) => (
|
|
540
|
+
<span
|
|
541
|
+
style={{ display: "inline-block", verticalAlign: "middle", marginRight: "5px", ...style }}
|
|
542
|
+
/>
|
|
543
|
+
);
|
|
544
|
+
return (
|
|
545
|
+
<div
|
|
546
|
+
style={{ fontFamily: fonts.mono, fontSize: "11px", lineHeight: "16px", color: palette.faint }}
|
|
547
|
+
>
|
|
548
|
+
{mark({ width: "8px", height: "8px", background: palette.ink })}
|
|
549
|
+
touch
|
|
550
|
+
<span style={{ display: "inline-block", width: "14px" }} />
|
|
551
|
+
{mark({ width: "5px", height: "5px", borderRadius: "50%", background: palette.accent })}
|
|
552
|
+
open
|
|
553
|
+
<span style={{ display: "inline-block", width: "14px" }} />
|
|
554
|
+
{`${days} days to today`}
|
|
555
|
+
</div>
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
|
|
443
559
|
export type RecordStackProps = { records: RecordStackItem[] };
|
|
444
560
|
|
|
445
561
|
/**
|
|
446
|
-
* One record per row, stacked: the title on its own line
|
|
447
|
-
*
|
|
448
|
-
*
|
|
449
|
-
* column
|
|
562
|
+
* One record per row, stacked: the title on its own line with its pills
|
|
563
|
+
* beside it, an optional timeline, a mono meta line with an optional aside
|
|
564
|
+
* set right, an optional note under that, an optional callout last. No
|
|
565
|
+
* column cells and no fixed widths, so a name of any length wraps instead of
|
|
566
|
+
* squashing on a phone. A fixed column table is for numbers alone; names go
|
|
567
|
+
* here. The pills, the timeline and the aside are the Company Cards IA
|
|
568
|
+
* (2026-09-29); a record without them renders as it always did.
|
|
450
569
|
*/
|
|
451
570
|
export function RecordStack({ records }: RecordStackProps) {
|
|
452
571
|
const { palette, fonts } = useEmailTheme();
|
|
@@ -457,12 +576,19 @@ export function RecordStack({ records }: RecordStackProps) {
|
|
|
457
576
|
color: palette.ink,
|
|
458
577
|
wordBreak: "break-word",
|
|
459
578
|
};
|
|
579
|
+
const metaStyle: CSSProperties = {
|
|
580
|
+
fontFamily: fonts.mono,
|
|
581
|
+
fontSize: "12px",
|
|
582
|
+
lineHeight: "1.5",
|
|
583
|
+
color: palette.faint,
|
|
584
|
+
};
|
|
460
585
|
return (
|
|
461
586
|
<table {...tableReset} width="100%" style={{ fontFamily: fonts.sans, color: palette.ink }}>
|
|
462
587
|
<tbody>
|
|
463
588
|
{records.map((record, i) => {
|
|
464
589
|
const last = i === records.length - 1;
|
|
465
590
|
const meta = record.meta.filter((m) => m !== "");
|
|
591
|
+
const badges = recordBadges(record);
|
|
466
592
|
const title = record.url ? (
|
|
467
593
|
<a
|
|
468
594
|
href={record.url}
|
|
@@ -477,6 +603,19 @@ export function RecordStack({ records }: RecordStackProps) {
|
|
|
477
603
|
) : (
|
|
478
604
|
record.title
|
|
479
605
|
);
|
|
606
|
+
const aside = record.aside ? (
|
|
607
|
+
<>
|
|
608
|
+
{record.aside.text}
|
|
609
|
+
{record.aside.strong ? (
|
|
610
|
+
<>
|
|
611
|
+
{" "}
|
|
612
|
+
<b style={{ color: asideColor(record.aside.tone, palette) }}>
|
|
613
|
+
{record.aside.strong}
|
|
614
|
+
</b>
|
|
615
|
+
</>
|
|
616
|
+
) : null}
|
|
617
|
+
</>
|
|
618
|
+
) : null;
|
|
480
619
|
return (
|
|
481
620
|
// biome-ignore lint/suspicious/noArrayIndexKey: a record's position is its identity
|
|
482
621
|
<tr key={i}>
|
|
@@ -487,22 +626,28 @@ export function RecordStack({ records }: RecordStackProps) {
|
|
|
487
626
|
...(last ? {} : { borderBottom: `1px solid ${palette.line}` }),
|
|
488
627
|
}}
|
|
489
628
|
>
|
|
490
|
-
{
|
|
491
|
-
<table {...tableReset} width="100%">
|
|
629
|
+
{badges.length > 0 ? (
|
|
630
|
+
<table {...tableReset} width="100%" style={titleStyle}>
|
|
492
631
|
<tbody>
|
|
493
632
|
<tr>
|
|
494
|
-
<td
|
|
633
|
+
<td valign="top">{title}</td>
|
|
495
634
|
<td
|
|
496
635
|
align="right"
|
|
636
|
+
valign="top"
|
|
497
637
|
style={{
|
|
498
638
|
textAlign: "right",
|
|
499
639
|
whiteSpace: "nowrap",
|
|
500
|
-
verticalAlign: "top",
|
|
501
640
|
paddingLeft: "12px",
|
|
502
641
|
paddingTop: "2px",
|
|
503
642
|
}}
|
|
504
643
|
>
|
|
505
|
-
|
|
644
|
+
{badges.map((badge, j) => (
|
|
645
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: a pill's position is its identity
|
|
646
|
+
<Fragment key={j}>
|
|
647
|
+
{j === 0 ? null : " "}
|
|
648
|
+
<RecordBadge text={badge.text} tone={badge.tone} />
|
|
649
|
+
</Fragment>
|
|
650
|
+
))}
|
|
506
651
|
</td>
|
|
507
652
|
</tr>
|
|
508
653
|
</tbody>
|
|
@@ -510,18 +655,22 @@ export function RecordStack({ records }: RecordStackProps) {
|
|
|
510
655
|
) : (
|
|
511
656
|
<div style={titleStyle}>{title}</div>
|
|
512
657
|
)}
|
|
513
|
-
{
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
658
|
+
{record.timeline ? <RecordTimelineStrip timeline={record.timeline} /> : null}
|
|
659
|
+
{meta.length > 0 || aside ? (
|
|
660
|
+
aside ? (
|
|
661
|
+
<table {...tableReset} width="100%" style={{ ...metaStyle, marginTop: "4px" }}>
|
|
662
|
+
<tbody>
|
|
663
|
+
<tr>
|
|
664
|
+
<td>{meta.join(" · ")}</td>
|
|
665
|
+
<td align="right" style={{ whiteSpace: "nowrap", paddingLeft: "12px" }}>
|
|
666
|
+
{aside}
|
|
667
|
+
</td>
|
|
668
|
+
</tr>
|
|
669
|
+
</tbody>
|
|
670
|
+
</table>
|
|
671
|
+
) : (
|
|
672
|
+
<div style={{ ...metaStyle, marginTop: "4px" }}>{meta.join(" · ")}</div>
|
|
673
|
+
)
|
|
525
674
|
) : null}
|
|
526
675
|
{record.note ? (
|
|
527
676
|
<div
|
package/src/email/text.ts
CHANGED
|
@@ -124,6 +124,28 @@ export function textTable(cols: string[], rows: string[][], right: number[]): st
|
|
|
124
124
|
return textTableGrid(cols, rows, right, null);
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/** A pill on a record's title line: faint is the quiet stage word, ink and accent are the Company Cards tones. */
|
|
128
|
+
export type RecordBadgeTone = "faint" | "ink" | "accent";
|
|
129
|
+
export type RecordBadge = { text: string; tone?: RecordBadgeTone };
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* A record's last `days` days on one line, today at the right edge: each
|
|
133
|
+
* send as a day count back from today, and each known open the same way.
|
|
134
|
+
* The html draws squares and dots on a baseline; the text twin draws one
|
|
135
|
+
* character per day.
|
|
136
|
+
*/
|
|
137
|
+
export type RecordTimeline = {
|
|
138
|
+
days: number;
|
|
139
|
+
touches: number[];
|
|
140
|
+
opens: number[];
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/** The color a record's aside number wears: `up` is fresh, `warm` is cooling, `accent` is cold. */
|
|
144
|
+
export type RecordAsideTone = "up" | "warm" | "accent";
|
|
145
|
+
|
|
146
|
+
/** A fact set right on the meta line, its number in a tone: "opened" and "6d". */
|
|
147
|
+
export type RecordAside = { text: string; strong?: string; tone?: RecordAsideTone };
|
|
148
|
+
|
|
127
149
|
/**
|
|
128
150
|
* One record in a stack: what `RecordStack` renders and `recordStackText`
|
|
129
151
|
* writes. It lives here, with no React, so a plain text renderer can import it.
|
|
@@ -137,14 +159,44 @@ export type RecordStackItem = {
|
|
|
137
159
|
note?: string;
|
|
138
160
|
/** A stage word on the title line, right aligned in a pill; "[Contacted]" in the text twin. */
|
|
139
161
|
badge?: string;
|
|
162
|
+
/** More pills after `badge`, each in its tone: New in the accent, the next touch in ink, Wait faint (Company Cards, 2026-09-29). */
|
|
163
|
+
badges?: RecordBadge[];
|
|
164
|
+
/** The record's recent days, drawn between the title and the meta line. */
|
|
165
|
+
timeline?: RecordTimeline;
|
|
166
|
+
/** A fact set right on the meta line, its number colored by its tone. */
|
|
167
|
+
aside?: RecordAside;
|
|
140
168
|
/** A short task under the record, an orange eyebrow over a line of text. */
|
|
141
169
|
callout?: { eyebrow: string; text: string };
|
|
142
170
|
};
|
|
143
171
|
|
|
172
|
+
/** Every pill on a record's title line, the quiet stage word first. */
|
|
173
|
+
export function recordBadges(record: RecordStackItem): RecordBadge[] {
|
|
174
|
+
const badges: RecordBadge[] = record.badge ? [{ text: record.badge, tone: "faint" }] : [];
|
|
175
|
+
return [...badges, ...(record.badges ?? [])];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The timeline as one character per day, oldest on the left and today on the
|
|
180
|
+
* right: `#` a send, `o` an open, `@` both on one day, `·` nothing, then `|`
|
|
181
|
+
* for today's edge. "30d [#····o··@···|]" reads the same way the drawn one does.
|
|
182
|
+
*/
|
|
183
|
+
export function recordTimelineText(timeline: RecordTimeline): string {
|
|
184
|
+
const days = Math.max(1, Math.round(timeline.days));
|
|
185
|
+
const touch = new Set(timeline.touches.map((d) => Math.round(d)));
|
|
186
|
+
const open = new Set(timeline.opens.map((d) => Math.round(d)));
|
|
187
|
+
let strip = "";
|
|
188
|
+
for (let ago = days - 1; ago >= 0; ago -= 1) {
|
|
189
|
+
strip +=
|
|
190
|
+
touch.has(ago) && open.has(ago) ? "@" : touch.has(ago) ? "#" : open.has(ago) ? "o" : "·";
|
|
191
|
+
}
|
|
192
|
+
return `${days}d [${strip}|]`;
|
|
193
|
+
}
|
|
194
|
+
|
|
144
195
|
/**
|
|
145
196
|
* The plain text twin of `RecordStack`: each title on its own line under a
|
|
146
|
-
* two space indent with
|
|
147
|
-
*
|
|
197
|
+
* two space indent with every badge after it in square brackets, the
|
|
198
|
+
* timeline as a character strip, the meta joined by middle dots with the
|
|
199
|
+
* aside at its end and the note beneath it, all wrapped under a four space
|
|
148
200
|
* indent, then any callout as its eyebrow in upper case over its text under
|
|
149
201
|
* the same indent, a blank line between records. No line runs past
|
|
150
202
|
* `textWidth`.
|
|
@@ -152,9 +204,14 @@ export type RecordStackItem = {
|
|
|
152
204
|
export function recordStackText(records: RecordStackItem[]): string {
|
|
153
205
|
return records
|
|
154
206
|
.map((record) => {
|
|
155
|
-
const
|
|
156
|
-
|
|
207
|
+
const badges = recordBadges(record)
|
|
208
|
+
.map((b) => ` [${b.text}]`)
|
|
209
|
+
.join("");
|
|
210
|
+
const lines = [wrapIndented(`${record.title}${badges}`, 2)];
|
|
211
|
+
if (record.timeline) lines.push(wrapIndented(recordTimelineText(record.timeline), 4));
|
|
157
212
|
const meta = record.meta.filter((m) => m !== "");
|
|
213
|
+
if (record.aside)
|
|
214
|
+
meta.push([record.aside.text, record.aside.strong ?? ""].filter((x) => x !== "").join(" "));
|
|
158
215
|
if (meta.length > 0) lines.push(wrapIndented(meta.join(" · "), 4));
|
|
159
216
|
if (record.note) lines.push(wrapIndented(record.note, 4));
|
|
160
217
|
if (record.callout) {
|
package/src/tokens/palettes.ts
CHANGED
|
@@ -32,6 +32,11 @@ export type Palette = {
|
|
|
32
32
|
up?: string;
|
|
33
33
|
/** A change for the worse, the negative delta's color. Optional the same way. */
|
|
34
34
|
down?: string;
|
|
35
|
+
/**
|
|
36
|
+
* The cooling tone between `up` and the accent: a record opened inside two
|
|
37
|
+
* weeks but not one (Company Cards, 0.7.0). Optional the same way.
|
|
38
|
+
*/
|
|
39
|
+
warm?: string;
|
|
35
40
|
};
|
|
36
41
|
|
|
37
42
|
export const atelicPalette: Palette = {
|
|
@@ -45,6 +50,7 @@ export const atelicPalette: Palette = {
|
|
|
45
50
|
hair: "#F0EAE0",
|
|
46
51
|
up: "#2F7A4B",
|
|
47
52
|
down: "#B23A2E",
|
|
53
|
+
warm: "#B7791F",
|
|
48
54
|
};
|
|
49
55
|
|
|
50
56
|
/**
|