From f6518e87bf928da49feeae5a11724662b999d242 Mon Sep 17 00:00:00 2001 From: PerishCode Date: Thu, 11 Jun 2026 19:43:40 +0800 Subject: [PATCH 1/2] web: converge shell visual language and fix core stamp rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continue the low-chroma tool-surface direction: tighten shell density, hairline + whisper-shadow containment, denser rail rows with clearer default/selected differentiation, compact badge/chip language, and single-line truncation for long titles in constrained chrome. Fix a real display bug: core emits `-` stamps, not ISO dates, so `new Date(value)` fell through to printing the raw string (e.g. `1781170545-087`) in both the rail and the transcript. Add a shared `parseCoreStamp` helper in web — product-semantic knowledge of the core contract belongs here, not in the business-blind component layer. Quiet the header chip system: drop the always-on idle/open/new chips and show only meaningful states (sending, reconnecting). Normalize the leftover all-caps `CONVERSATIONS` rail label to match the calmer descriptor language. Co-Authored-By: Claude Opus 4.8 --- .../soma/web/src/components/ChatHeader.tsx | 18 ++-- .../soma/web/src/components/SessionRail.tsx | 20 ++-- .../web/src/components/TimelineItemView.tsx | 6 +- .../soma/web/src/components/coreStamp.ts | 13 +++ .../components/src/atoms/Badge/Badge.scss | 18 +++- .../components/src/atoms/Button/Button.scss | 19 ++-- packages/components/src/atoms/Grid/Grid.scss | 2 +- .../SectionStackLayout.scss | 4 +- packages/components/src/theme/default.scss | 12 +-- .../components/src/theme/tokens.border.scss | 10 +- .../components/src/theme/tokens.colors.scss | 99 ++++++++++--------- 11 files changed, 132 insertions(+), 89 deletions(-) create mode 100644 apps/client/soma/web/src/components/coreStamp.ts diff --git a/apps/client/soma/web/src/components/ChatHeader.tsx b/apps/client/soma/web/src/components/ChatHeader.tsx index bdc298f..1b41c5d 100644 --- a/apps/client/soma/web/src/components/ChatHeader.tsx +++ b/apps/client/soma/web/src/components/ChatHeader.tsx @@ -44,8 +44,7 @@ export function ChatHeader(props: { return ( - - SESSION + {editing ? ( ) : ( )} - - {props.busy ? "sending" : "idle"} - - {props.selectedSessionId ? {props.connection} : null} - {!props.selectedSessionId ? new : null} + {props.busy ? ( + sending + ) : null} + {props.connection === "error" ? ( + reconnecting + ) : null} diff --git a/apps/client/soma/web/src/components/SessionRail.tsx b/apps/client/soma/web/src/components/SessionRail.tsx index 6ba1e6b..5684a14 100644 --- a/apps/client/soma/web/src/components/SessionRail.tsx +++ b/apps/client/soma/web/src/components/SessionRail.tsx @@ -12,6 +12,8 @@ import { } from "@mini-stim/components"; import type { Session } from "@mini-stim/hooks"; +import { parseCoreStamp } from "./coreStamp"; + export function SessionRail(props: { busy: boolean; onCreate: () => void; @@ -25,8 +27,8 @@ export function SessionRail(props: { - WORKSPACE - mini-stim + mini-stim + Local-first AI chat @@ -87,8 +91,8 @@ function sessionLabel(session: { id: string; title?: string | null }) { } function formatSessionStamp(value: string) { - const date = new Date(value); - if (Number.isNaN(date.getTime())) { + const date = parseCoreStamp(value); + if (!date) { return value; } return new Intl.DateTimeFormat(undefined, { diff --git a/apps/client/soma/web/src/components/TimelineItemView.tsx b/apps/client/soma/web/src/components/TimelineItemView.tsx index 3938f73..f013c4f 100644 --- a/apps/client/soma/web/src/components/TimelineItemView.tsx +++ b/apps/client/soma/web/src/components/TimelineItemView.tsx @@ -7,6 +7,8 @@ import { } from "@mini-stim/components"; import type { TimelineItem } from "@mini-stim/hooks"; +import { parseCoreStamp } from "./coreStamp"; + export function TimelineItemView(props: { item: TimelineItem; }) { @@ -126,8 +128,8 @@ function roleLabel(role: string) { } function formatStamp(value: string) { - const date = new Date(value); - if (Number.isNaN(date.getTime())) { + const date = parseCoreStamp(value); + if (!date) { return value; } return new Intl.DateTimeFormat(undefined, { diff --git a/apps/client/soma/web/src/components/coreStamp.ts b/apps/client/soma/web/src/components/coreStamp.ts new file mode 100644 index 0000000..b408e0e --- /dev/null +++ b/apps/client/soma/web/src/components/coreStamp.ts @@ -0,0 +1,13 @@ +// core stamps are `-` strings, not ISO dates. +// This is product-semantic knowledge of the core contract, so it lives in web +// rather than in the business-blind component layer. +export function parseCoreStamp(value: string): Date | null { + const match = /^(\d+)-(\d{1,3})$/.exec(value.trim()); + if (!match) { + const iso = new Date(value); + return Number.isNaN(iso.getTime()) ? null : iso; + } + const seconds = Number(match[1]); + const millis = Number(match[2]); + return new Date(seconds * 1000 + millis); +} diff --git a/packages/components/src/atoms/Badge/Badge.scss b/packages/components/src/atoms/Badge/Badge.scss index 23b18c2..18a32d4 100644 --- a/packages/components/src/atoms/Badge/Badge.scss +++ b/packages/components/src/atoms/Badge/Badge.scss @@ -1,45 +1,53 @@ .msBadge { --ms-badge-bg: var(--ms-color-badge-neutral-bg); + --ms-badge-border: var(--ms-color-badge-neutral-border); --ms-badge-text: var(--ms-color-badge-neutral-text); display: inline-flex; align-items: center; + justify-content: center; min-width: 0; - border: 1px solid var(--ms-color-border); + min-height: 1.5rem; + border: 1px solid var(--ms-badge-border); border-radius: 999px; background: var(--ms-badge-bg); color: var(--ms-badge-text); - font-weight: 600; - letter-spacing: 0.02em; + font-weight: 650; + letter-spacing: 0.01em; line-height: 1.15; + white-space: nowrap; &--size-sm { - padding: 0.1875rem 0.5rem; + padding: 0.125rem 0.5rem; font-size: var(--ms-font-size-xs); } &--size-md { - padding: 0.25rem 0.625rem; + padding: 0.1875rem 0.625rem; font-size: var(--ms-font-size-sm); } &--tone-neutral { --ms-badge-bg: var(--ms-color-badge-neutral-bg); + --ms-badge-border: var(--ms-color-badge-neutral-border); --ms-badge-text: var(--ms-color-badge-neutral-text); } &--tone-accent { --ms-badge-bg: var(--ms-color-badge-accent-bg); + --ms-badge-border: var(--ms-color-badge-accent-border); --ms-badge-text: var(--ms-color-badge-accent-text); } &--tone-success { --ms-badge-bg: var(--ms-color-badge-success-bg); + --ms-badge-border: var(--ms-color-badge-success-border); --ms-badge-text: var(--ms-color-badge-success-text); } &--tone-danger { --ms-badge-bg: var(--ms-color-badge-danger-bg); + --ms-badge-border: var(--ms-color-badge-danger-border); --ms-badge-text: var(--ms-color-badge-danger-text); } } diff --git a/packages/components/src/atoms/Button/Button.scss b/packages/components/src/atoms/Button/Button.scss index 0a766cd..b5593e5 100644 --- a/packages/components/src/atoms/Button/Button.scss +++ b/packages/components/src/atoms/Button/Button.scss @@ -25,6 +25,7 @@ $button-sizes: ( --ms-button-text: var(--ms-color-button-text); --ms-button-hover-bg: var(--ms-color-button-hover-bg); --ms-button-hover-border: var(--ms-color-border-strong); + --ms-button-hover-shadow: var(--ms-shadow-button); display: inline-flex; position: relative; @@ -47,6 +48,7 @@ $button-sizes: ( &:not(:disabled):hover { background: var(--ms-button-hover-bg); border-color: var(--ms-button-hover-border); + box-shadow: var(--ms-button-hover-shadow); transform: translateY(-1px); } @@ -93,11 +95,12 @@ $button-sizes: ( } &--variant-outline { - --ms-button-bg: var(--ms-color-button-bg); + --ms-button-bg: var(--ms-color-button-subtle-bg); --ms-button-border: var(--ms-color-button-border); --ms-button-text: var(--ms-color-text); --ms-button-hover-bg: var(--ms-color-button-hover-bg); --ms-button-hover-border: var(--ms-color-border-strong); + --ms-button-hover-shadow: var(--ms-shadow-card); } &--variant-ghost { @@ -121,18 +124,19 @@ $button-sizes: ( &--variant-rail, &--variant-rail-selected { justify-content: flex-start; - border-radius: var(--ms-radius-xl); + border-radius: calc(var(--ms-radius-xl) - var(--ms-space-1)); box-shadow: none; text-align: left; - padding-block: var(--ms-space-3); + padding-block: calc(var(--ms-space-2) + var(--ms-space-1)); } &--variant-rail { - --ms-button-bg: transparent; - --ms-button-border: transparent; + --ms-button-bg: rgba(255, 255, 255, 0.52); + --ms-button-border: var(--ms-color-border-faint); --ms-button-text: var(--ms-color-text); --ms-button-hover-bg: var(--ms-color-rail-hover-bg); - --ms-button-hover-border: transparent; + --ms-button-hover-border: var(--ms-color-rail-hover-border); + --ms-button-hover-shadow: var(--ms-shadow-card); } &--variant-rail-selected { @@ -141,11 +145,12 @@ $button-sizes: ( --ms-button-text: var(--ms-color-text-strong); --ms-button-hover-bg: var(--ms-color-selected-hover-bg); --ms-button-hover-border: var(--ms-color-rail-active-border); + --ms-button-hover-shadow: var(--ms-shadow-card); &::before { content: ""; position: absolute; - inset: 0 auto 0 0; + inset: 0.5rem auto 0.5rem 0; width: 0.1875rem; border-radius: 999px; background: var(--ms-color-rail-active-indicator); diff --git a/packages/components/src/atoms/Grid/Grid.scss b/packages/components/src/atoms/Grid/Grid.scss index 3254992..6418a26 100644 --- a/packages/components/src/atoms/Grid/Grid.scss +++ b/packages/components/src/atoms/Grid/Grid.scss @@ -18,7 +18,7 @@ $grid-gaps: ( } &--template-sidebar-main { - grid-template-columns: minmax(18rem, 22rem) minmax(0, 1fr); + grid-template-columns: minmax(16.5rem, 19rem) minmax(0, 1fr); @media (max-width: 48rem) { grid-template-columns: 1fr; diff --git a/packages/components/src/patterns/SectionStackLayout/SectionStackLayout.scss b/packages/components/src/patterns/SectionStackLayout/SectionStackLayout.scss index bd862f5..8228c65 100644 --- a/packages/components/src/patterns/SectionStackLayout/SectionStackLayout.scss +++ b/packages/components/src/patterns/SectionStackLayout/SectionStackLayout.scss @@ -4,7 +4,7 @@ flex-direction: column; min-width: 0; min-height: 0; - padding: var(--ms-space-3); + padding: calc(var(--ms-space-2) + var(--ms-space-1)); &__top, &__middle, @@ -38,7 +38,7 @@ } &__top::after { - bottom: calc(var(--ms-space-3) * -0.5); + bottom: calc((var(--ms-space-2) + var(--ms-space-1)) * -0.5); } &__top > *, diff --git a/packages/components/src/theme/default.scss b/packages/components/src/theme/default.scss index 765cdc0..f3036c7 100644 --- a/packages/components/src/theme/default.scss +++ b/packages/components/src/theme/default.scss @@ -9,9 +9,9 @@ color: var(--ms-color-text); background: var(--ms-color-canvas); font-family: var(--ms-font-family-sans); - --ms-shell-padding: var(--ms-space-3); - --ms-shell-gap: calc(var(--ms-space-3) * 0.8); - --ms-shell-radius: calc(var(--ms-radius-xl) - var(--ms-space-1)); + --ms-shell-padding: calc(var(--ms-space-2) + var(--ms-space-1)); + --ms-shell-gap: var(--ms-space-2); + --ms-shell-radius: calc(var(--ms-radius-xl) - var(--ms-space-2)); --ms-shell-shadow: var(--ms-shadow-shell); } @@ -29,9 +29,9 @@ body, body { margin: 0; background: - radial-gradient(circle at top left, rgba(255, 255, 255, 0.88), transparent 24rem), - radial-gradient(circle at bottom right, rgba(196, 214, 231, 0.26), transparent 20rem), - linear-gradient(180deg, #eef4f8 0%, var(--ms-color-canvas) 100%); + radial-gradient(circle at top left, rgba(255, 255, 255, 0.82), transparent 21rem), + radial-gradient(circle at bottom right, rgba(179, 197, 214, 0.22), transparent 18rem), + linear-gradient(180deg, #edf2f7 0%, var(--ms-color-canvas) 100%); color: var(--ms-color-text); font-family: var(--ms-font-family-sans); overflow: hidden; diff --git a/packages/components/src/theme/tokens.border.scss b/packages/components/src/theme/tokens.border.scss index fabcffc..c47b4ba 100644 --- a/packages/components/src/theme/tokens.border.scss +++ b/packages/components/src/theme/tokens.border.scss @@ -1,6 +1,10 @@ :root { - --ms-shadow-shell: 0 18px 42px -28px rgba(36, 56, 74, 0.28); - --ms-shadow-card: 0 10px 24px -22px rgba(34, 51, 66, 0.3); + --ms-shadow-shell: + 0 0 0 1px rgba(129, 147, 164, 0.08), + 0 16px 34px -28px rgba(25, 42, 59, 0.34); + --ms-shadow-card: + 0 0 0 1px rgba(123, 143, 161, 0.06), + 0 8px 18px -18px rgba(28, 44, 59, 0.22); --ms-shadow-button: none; - --ms-shadow-focus: 0 0 0 3px rgba(44, 108, 163, 0.14); + --ms-shadow-focus: 0 0 0 3px rgba(36, 93, 143, 0.14); } diff --git a/packages/components/src/theme/tokens.colors.scss b/packages/components/src/theme/tokens.colors.scss index 1009910..c575054 100644 --- a/packages/components/src/theme/tokens.colors.scss +++ b/packages/components/src/theme/tokens.colors.scss @@ -1,51 +1,52 @@ :root { - --ms-color-canvas: #e7edf3; - --ms-color-panel: #f6f9fc; + --ms-color-canvas: #e4ebf1; + --ms-color-panel: #f3f7fb; --ms-color-panel-raised: #fbfdff; - --ms-color-panel-subtle: #eef3f8; + --ms-color-panel-subtle: #e9eff5; --ms-color-surface: #ffffff; - --ms-color-surface-muted-bg: #f4f8fb; - --ms-color-surface-muted-border: #d5e0ea; - --ms-color-surface-inset-bg: #edf3f8; - --ms-color-surface-inset-border: #d4dde7; - --ms-color-text: #16202a; - --ms-color-text-strong: #101922; - --ms-color-text-muted: #5f7080; - --ms-color-text-subtle: #778898; - --ms-color-border: #d3dde7; - --ms-color-border-strong: #a8b7c5; - --ms-color-border-faint: rgba(161, 178, 194, 0.24); - --ms-color-frame-border: rgba(140, 162, 180, 0.28); + --ms-color-surface-muted-bg: #eef3f8; + --ms-color-surface-muted-border: #ccd8e4; + --ms-color-surface-inset-bg: #e8eef4; + --ms-color-surface-inset-border: #cad5e0; + --ms-color-text: #1b2834; + --ms-color-text-strong: #121c26; + --ms-color-text-muted: #57697a; + --ms-color-text-subtle: #718496; + --ms-color-border: #cad6e1; + --ms-color-border-strong: #9caebe; + --ms-color-border-faint: rgba(133, 152, 171, 0.24); + --ms-color-frame-border: rgba(116, 140, 165, 0.22); --ms-color-button-bg: #ffffff; - --ms-color-button-subtle-bg: #fbfdff; - --ms-color-button-hover-bg: #eef4f9; - --ms-color-button-border: #c5d1dc; - --ms-color-button-text: #1c2a35; - --ms-color-ghost-hover-bg: rgba(91, 116, 139, 0.08); - --ms-color-rail-hover-bg: rgba(74, 99, 124, 0.08); - --ms-color-rail-active-bg: #e1ebf5; - --ms-color-rail-active-border: #c5d6e7; - --ms-color-rail-active-indicator: #2c6899; - --ms-color-accent: #2a6598; - --ms-color-accent-border: #2a6598; - --ms-color-accent-hover: #225884; + --ms-color-button-subtle-bg: #f8fbfd; + --ms-color-button-hover-bg: #edf3f8; + --ms-color-button-border: #bccbd8; + --ms-color-button-text: #162532; + --ms-color-ghost-hover-bg: rgba(69, 96, 123, 0.08); + --ms-color-rail-hover-bg: #eef3f7; + --ms-color-rail-hover-border: #d4dee8; + --ms-color-rail-active-bg: #e3ebf4; + --ms-color-rail-active-border: #bccbd9; + --ms-color-rail-active-indicator: #255f92; + --ms-color-accent: #245d8f; + --ms-color-accent-border: #245d8f; + --ms-color-accent-hover: #1c4f7b; --ms-color-accent-text: #ffffff; - --ms-color-selected-bg: #e3edf7; - --ms-color-selected-hover-bg: #dae7f3; - --ms-color-selected-border: #a8c3dc; - --ms-color-selected-indicator: #2b648f; + --ms-color-selected-bg: #e1eaf3; + --ms-color-selected-hover-bg: #d8e3ee; + --ms-color-selected-border: #aac0d4; + --ms-color-selected-indicator: #265d8e; - --ms-color-input-bg: #fdfefe; - --ms-color-input-dock-bg: #f7fbfe; - --ms-color-input-border: #cad6e0; + --ms-color-input-bg: #fcfeff; + --ms-color-input-dock-bg: #f5f8fb; + --ms-color-input-border: #c1cfdb; --ms-color-input-focus-bg: #ffffff; --ms-color-dock-bg: rgba(244, 248, 252, 0.96); - --ms-color-dock-border: #d6e1eb; + --ms-color-dock-border: #cdd9e4; - --ms-color-surface-accent-bg: #dbe8f4; - --ms-color-surface-accent-border: #a7c1d7; - --ms-color-surface-accent-text: #163a54; + --ms-color-surface-accent-bg: #d8e6f2; + --ms-color-surface-accent-border: #9fb9cf; + --ms-color-surface-accent-text: #16374f; --ms-color-surface-warning-bg: #fff4e4; --ms-color-surface-warning-border: #ead1a5; @@ -59,16 +60,20 @@ --ms-color-danger-border: #e2aaaa; --ms-color-danger-text: #8d2222; - --ms-color-code-bg: #edf4f9; - --ms-color-code-border: #cdd9e4; + --ms-color-code-bg: #edf3f8; + --ms-color-code-border: #c8d4de; --ms-color-code-text: #22323e; - --ms-color-badge-neutral-bg: #eaf1f6; - --ms-color-badge-neutral-text: #476173; - --ms-color-badge-accent-bg: #dbe8f4; - --ms-color-badge-accent-text: #1a4565; - --ms-color-badge-success-bg: #dff1e8; - --ms-color-badge-success-text: #21553d; - --ms-color-badge-danger-bg: #fff0f0; + --ms-color-badge-neutral-bg: #eef3f7; + --ms-color-badge-neutral-border: #d5dde6; + --ms-color-badge-neutral-text: #425768; + --ms-color-badge-accent-bg: #e0ebf4; + --ms-color-badge-accent-border: #bed0df; + --ms-color-badge-accent-text: #204761; + --ms-color-badge-success-bg: #e3f0e9; + --ms-color-badge-success-border: #c6dbd0; + --ms-color-badge-success-text: #28553f; + --ms-color-badge-danger-bg: #fff1f1; + --ms-color-badge-danger-border: #edc6c6; --ms-color-badge-danger-text: #8d2222; } From c1578da59a001864e307eff6d9074f1201be0873 Mon Sep 17 00:00:00 2001 From: PerishCode Date: Thu, 11 Jun 2026 20:13:06 +0800 Subject: [PATCH 2/2] timestamps: emit ISO 8601 from core, add Timestamp atom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Push the timestamp display problem back to its correct ownership layers instead of patching it in web. Source (santi-core): `timestamp_now` produced `-` strings, not ISO dates. Switch to jiff with fixed millisecond precision (`DateTimePrinter::precision(Some(3))`), emitting RFC3339 UTC like `2026-06-11T12:06:58.539Z`. Fixed-width fractional digits keep the value lexicographically sortable, which both the SQLite `ORDER BY` and the browser projection `localeCompare` sort keys rely on. The `Timestamp = String` alias is unchanged, so the generated OpenAPI contract is untouched — only the runtime value's semantics change. This also aligns the server with the optimistic projection in mqueue, which already emitted ISO via `new Date().toISOString()`. Component layer: add a business-blind `Timestamp` atom backed by dayjs (relativeTime + utc + localizedFormat). It renders a semantic ` @@ -89,16 +86,3 @@ export function SessionRail(props: { function sessionLabel(session: { id: string; title?: string | null }) { return session.title?.trim() || session.id; } - -function formatSessionStamp(value: string) { - const date = parseCoreStamp(value); - if (!date) { - return value; - } - return new Intl.DateTimeFormat(undefined, { - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - }).format(date); -} diff --git a/apps/client/soma/web/src/components/TimelineItemView.tsx b/apps/client/soma/web/src/components/TimelineItemView.tsx index f013c4f..d96a21c 100644 --- a/apps/client/soma/web/src/components/TimelineItemView.tsx +++ b/apps/client/soma/web/src/components/TimelineItemView.tsx @@ -4,11 +4,10 @@ import { Stack, Surface, Text, + Timestamp, } from "@mini-stim/components"; import type { TimelineItem } from "@mini-stim/hooks"; -import { parseCoreStamp } from "./coreStamp"; - export function TimelineItemView(props: { item: TimelineItem; }) { @@ -28,9 +27,7 @@ export function TimelineItemView(props: { {roleLabel(role)} - - {formatStamp(item.createdAt)} - + {item.message.content_text} @@ -126,14 +123,3 @@ function roleLabel(role: string) { } return "ASSISTANT"; } - -function formatStamp(value: string) { - const date = parseCoreStamp(value); - if (!date) { - return value; - } - return new Intl.DateTimeFormat(undefined, { - hour: "numeric", - minute: "2-digit", - }).format(date); -} diff --git a/apps/client/soma/web/src/components/coreStamp.ts b/apps/client/soma/web/src/components/coreStamp.ts deleted file mode 100644 index b408e0e..0000000 --- a/apps/client/soma/web/src/components/coreStamp.ts +++ /dev/null @@ -1,13 +0,0 @@ -// core stamps are `-` strings, not ISO dates. -// This is product-semantic knowledge of the core contract, so it lives in web -// rather than in the business-blind component layer. -export function parseCoreStamp(value: string): Date | null { - const match = /^(\d+)-(\d{1,3})$/.exec(value.trim()); - if (!match) { - const iso = new Date(value); - return Number.isNaN(iso.getTime()) ? null : iso; - } - const seconds = Number(match[1]); - const millis = Number(match[2]); - return new Date(seconds * 1000 + millis); -} diff --git a/apps/server/soma/crates/santi-core/Cargo.toml b/apps/server/soma/crates/santi-core/Cargo.toml index 4e626be..a1dcdbb 100644 --- a/apps/server/soma/crates/santi-core/Cargo.toml +++ b/apps/server/soma/crates/santi-core/Cargo.toml @@ -9,6 +9,7 @@ async-stream.workspace = true async-trait.workspace = true futures-core.workspace = true futures-util.workspace = true +jiff.workspace = true rusqlite.workspace = true santi-provider.workspace = true serde.workspace = true diff --git a/apps/server/soma/crates/santi-core/src/model.rs b/apps/server/soma/crates/santi-core/src/model.rs index 10b76c9..54ab001 100644 --- a/apps/server/soma/crates/santi-core/src/model.rs +++ b/apps/server/soma/crates/santi-core/src/model.rs @@ -340,12 +340,20 @@ pub struct SessionRuntimeSnapshot { } pub fn timestamp_now() -> Timestamp { - use std::time::{SystemTime, UNIX_EPOCH}; - - let duration = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock before unix epoch"); - format!("{}-{:03}", duration.as_secs(), duration.subsec_millis()) + use jiff::fmt::temporal::DateTimePrinter; + + // RFC3339 / ISO 8601 UTC with fixed millisecond precision. Fixed-width + // fractional digits keep the string lexicographically sortable, which the + // store and the browser projection both rely on (timestamps are used as + // `ORDER BY` / `localeCompare` sort keys). A `jiff::Timestamp` is UTC, so + // the printed form ends in `Z`. + let now = jiff::Timestamp::now(); + let mut buf = String::new(); + DateTimePrinter::new() + .precision(Some(3)) + .print_timestamp(&now, &mut buf) + .expect("formatting a timestamp into a String cannot fail"); + buf } pub fn prefixed_id(prefix: &str) -> String { diff --git a/packages/components/package.json b/packages/components/package.json index 0696afe..e4a4481 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -11,6 +11,7 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { + "dayjs": "^1.11.21", "lucide-react": "^1.17.0", "react": "^19.2.7" }, diff --git a/packages/components/src/atoms/Text/Text.tsx b/packages/components/src/atoms/Text/Text.tsx index 71dddfa..1d60778 100644 --- a/packages/components/src/atoms/Text/Text.tsx +++ b/packages/components/src/atoms/Text/Text.tsx @@ -4,8 +4,11 @@ import { cx } from "../../internal/cx"; import "./Text.scss"; type TextProps = ComponentPropsWithoutRef<"span"> & { + // `dateTime` is only meaningful on tag="time"; allow it without widening the + // whole prop surface to a polymorphic union. + dateTime?: string; size?: "xs" | "sm" | "md" | "lg"; - tag?: "p" | "span" | "small"; + tag?: "p" | "span" | "small" | "time"; tone?: "default" | "strong" | "muted" | "subtle"; truncate?: boolean; }; diff --git a/packages/components/src/atoms/Timestamp/Timestamp.tsx b/packages/components/src/atoms/Timestamp/Timestamp.tsx new file mode 100644 index 0000000..f62c1a4 --- /dev/null +++ b/packages/components/src/atoms/Timestamp/Timestamp.tsx @@ -0,0 +1,56 @@ +import { useMemo } from "react"; + +import dayjs from "dayjs"; +import localizedFormat from "dayjs/plugin/localizedFormat"; +import relativeTime from "dayjs/plugin/relativeTime"; +import utc from "dayjs/plugin/utc"; + +import { Text } from "../Text/Text"; + +dayjs.extend(utc); +dayjs.extend(relativeTime); +dayjs.extend(localizedFormat); + +type TimestampProps = { + // ISO 8601 string (UTC, e.g. "2026-06-11T09:35:45.087Z"). + value: string; + // "relative" → "3 minutes ago"; "absolute" → localized date-time. + variant?: "relative" | "absolute"; + size?: "xs" | "sm" | "md" | "lg"; + tone?: "default" | "strong" | "muted" | "subtle"; + truncate?: boolean; +}; + +export function Timestamp({ + value, + variant = "relative", + size = "xs", + tone = "subtle", + truncate = false, +}: TimestampProps) { + const { display, absolute, dateTime } = useMemo(() => { + const parsed = dayjs(value); + if (!parsed.isValid()) { + return { display: value, absolute: value, dateTime: undefined }; + } + const absolute = parsed.format("LLL"); + return { + display: variant === "relative" ? parsed.fromNow() : absolute, + absolute, + dateTime: parsed.toISOString(), + }; + }, [value, variant]); + + return ( + + {display} + + ); +} diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 243f739..10e00a4 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -20,3 +20,4 @@ export { Stack } from "./atoms/Stack/Stack"; export { Surface } from "./atoms/Surface/Surface"; export { Text } from "./atoms/Text/Text"; export { TextArea } from "./atoms/TextArea/TextArea"; +export { Timestamp } from "./atoms/Timestamp/Timestamp"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19d0329..0f973e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,6 +57,9 @@ importers: packages/components: dependencies: + dayjs: + specifier: ^1.11.21 + version: 1.11.21 lucide-react: specifier: ^1.17.0 version: 1.17.0(react@19.2.7) @@ -656,6 +659,9 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1594,6 +1600,8 @@ snapshots: csstype@3.2.3: {} + dayjs@1.11.21: {} + debug@4.4.3: dependencies: ms: 2.1.3