@anchrd/intel-ui 0.22.0 → 0.23.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.
@@ -6,6 +6,7 @@ import { useNavigate, useRouterState } from "@tanstack/react-router";
6
6
  import {
7
7
  Archive,
8
8
  CornerLeftUp,
9
+ Download,
9
10
  Ellipsis,
10
11
  FileArchive,
11
12
  FolderDown,
@@ -36,6 +37,7 @@ import { useBundleImport } from "@/import-bundle/import-bundle.tsx";
36
37
  import { Modal } from "@/modal/modal.tsx";
37
38
  import { useIntelRouterContext } from "@/router/router-context.ts";
38
39
  import { selectedFrom } from "@/router/selection-search.ts";
40
+ import { useDateTime } from "@/time/time-context.tsx";
39
41
  import { useUserName } from "@/user-name/user-name.ts";
40
42
 
41
43
  /**
@@ -135,6 +137,7 @@ export function ResourceMenu({
135
137
  }) {
136
138
  const { data } = useIntelRouterContext();
137
139
  const i18n = useI18n();
140
+ const dateTime = useDateTime();
138
141
  const queryClient = useQueryClient();
139
142
  const navigate = useNavigate();
140
143
  const [renaming, setRenaming] = useState(false);
@@ -266,6 +269,37 @@ export function ResourceMenu({
266
269
  },
267
270
  });
268
271
 
272
+ // #454: a table's CSV export — an ENTRY, not a button of its own. It was the only control WITH
273
+ // TEXT beside two icon buttons in the title line, and it was needed no more often than what sits
274
+ // in here anyway; it was merely wider. Intel has made the same decision twice already (#363 for
275
+ // status and archive, #346 for the import).
276
+ //
277
+ // ⚠️ It lives HERE and no longer in `NodeTablePanel`: there it hung off the table query in order
278
+ // to prevent an empty export. The condition survives the move without a second query — the
279
+ // canonical content IS the CSV, and if it is empty nothing is downloaded, it is refused instead.
280
+ // A file of zero bytes is the worse answer.
281
+ const downloadCsv = useMutation({
282
+ mutationFn: async () => {
283
+ if (target.type !== "node") throw new Error("csv export is a node's");
284
+ const document_ = await data.getNode(target.node.id);
285
+ const content = document_.content ?? "";
286
+ if (content === "") throw new Error("nothing to export yet");
287
+ return new Blob([content], { type: "text/csv;charset=utf-8" });
288
+ },
289
+ onSuccess: (blob) => {
290
+ const url = URL.createObjectURL(blob);
291
+ const anchor = document.createElement("a");
292
+ anchor.href = url;
293
+ // No doubled `.csv` when the title already carries the extension — unchanged from #40.
294
+ anchor.download = title.toLowerCase().endsWith(".csv") ? title : `${title}.csv`;
295
+ document.body.append(anchor);
296
+ anchor.click();
297
+ anchor.remove();
298
+ setTimeout(() => URL.revokeObjectURL(url), 0);
299
+ },
300
+ });
301
+ const isTable = target.type === "node" && target.node.kind === "table";
302
+
269
303
  const failure = rename.isError
270
304
  ? null // the rename dialog words its own refusal, beside the field that caused it
271
305
  : archive.isError
@@ -291,9 +325,25 @@ export function ResourceMenu({
291
325
  <Pencil aria-hidden="true" />
292
326
  {i18n.t("resource.rename")}
293
327
  </DropdownMenuItem>
328
+ {/* ⚠️ #455: the hint is the actual change of the ticket. Dragging in the tree has been
329
+ built and shipped since 2026-08-02 — Jack asked for it anyway, so the feature was not
330
+ the problem, its discoverability was. Nothing about a row says it is movable before you
331
+ touch it: `cursor-grab` is only visible once the pointer is already on it, and the root
332
+ drop target appears only while something is being carried.
333
+
334
+ A menu is where you learn what can be done with a thing — the same place an application
335
+ names its keyboard shortcuts. No handle and no extra icon (Jack's instruction), and
336
+ readable without the pointer ever touching the row.
337
+
338
+ ⚠️ The dialog behind it stays the main path, not the fallback: touch has no
339
+ `dragstart`, and nothing can be dragged by keyboard at all. Dragging is the shortcut
340
+ for the mouse — the hint says exactly that, in the place that is reachable both ways. */}
294
341
  <DropdownMenuItem onSelect={() => move.start(entryOf(target), null)}>
295
342
  <CornerLeftUp aria-hidden="true" />
296
343
  {i18n.t("tree.move.action")}
344
+ <span aria-hidden="true" className="ml-auto text-xs text-muted-foreground">
345
+ {i18n.t("tree.move.hint")}
346
+ </span>
297
347
  </DropdownMenuItem>
298
348
  {/* Every kind exports: a folder takes its subtree along, everything else is a bundle of
299
349
  one (#136). The entry sits with rename and move because it acts on the whole thing,
@@ -302,6 +352,14 @@ export function ResourceMenu({
302
352
  <FolderDown aria-hidden="true" />
303
353
  {i18n.t("resource.export")}
304
354
  </DropdownMenuItem>
355
+ {/* Only a table has a CSV — and it stands beside the bundle export because both do the
356
+ same thing: hand out the whole thing, not a part of its content. */}
357
+ {isTable ? (
358
+ <DropdownMenuItem onSelect={() => downloadCsv.mutate()}>
359
+ <Download aria-hidden="true" />
360
+ {i18n.t("node.table.download")}
361
+ </DropdownMenuItem>
362
+ ) : null}
305
363
  {/* The other half of the same round trip, next to it rather than in the tree's plus
306
364
  (#346): one word in the menu, both sources under it. */}
307
365
  {importable ? (
@@ -367,6 +425,9 @@ export function ResourceMenu({
367
425
  {/* Its own sentence, not `resourceErrorKey`'s: nothing was changed, something failed to
368
426
  arrive, and "the change was not saved" would send the reader looking for a change. */}
369
427
  {exportBundle.isError ? <MenuFailure>{i18n.t("resource.exportFailed")}</MenuFailure> : null}
428
+ {downloadCsv.isError ? (
429
+ <MenuFailure>{i18n.t("node.table.downloadFailed")}</MenuFailure>
430
+ ) : null}
370
431
  {/* The import's own sentence, and its own pickers — only where the entry exists, because two
371
432
  file inputs on every document's menu would be two elements nothing can ever open. */}
372
433
  {bundleImport.isError ? <MenuFailure>{i18n.t("tree.importFailed")}</MenuFailure> : null}
@@ -417,7 +478,7 @@ export function ResourceMenu({
417
478
  {/* A snapshot says when it was taken, or it will be read as a standing verdict. */}
418
479
  <p className="mt-4 text-xs text-muted-foreground">
419
480
  {i18n.t("flows.validateWhen", {
420
- when: new Date(validation.checkedAt).toLocaleString(),
481
+ when: dateTime.at(validation.checkedAt),
421
482
  })}
422
483
  </p>
423
484
  </Modal>
@@ -0,0 +1,72 @@
1
+ import { createContext, type ReactNode, useContext, useMemo, useState } from "react";
2
+ import { useI18n } from "@/i18n/i18n-context.tsx";
3
+ import {
4
+ DeviceZone,
5
+ formatDate,
6
+ formatDateTime,
7
+ readZoneChoice,
8
+ rememberZoneChoice,
9
+ type TimeZoneStore,
10
+ type ZoneChoice,
11
+ } from "./time.ts";
12
+
13
+ // The React seam for the zone. Like language and appearance it is CLIENT state — a preference in
14
+ // `localStorage`, never on Intel's HTTP surface (`packages/ui/CLAUDE.md`).
15
+ //
16
+ // ⚠️ Deliberately NOT inside the i18n context, although both concern presentation: the language
17
+ // decides HOW a time is written, the zone WHICH time it is. Merged, the zone would hang off the
18
+ // catalogue and a language switch would move the clock.
19
+
20
+ export interface ZoneSelection {
21
+ current: ZoneChoice;
22
+ select(choice: ZoneChoice): void;
23
+ }
24
+
25
+ const ZoneContext = createContext<ZoneSelection | null>(null);
26
+
27
+ export function TimeZoneProvider({
28
+ store,
29
+ children,
30
+ }: {
31
+ store?: TimeZoneStore;
32
+ children: ReactNode;
33
+ }) {
34
+ const [choice, setChoice] = useState<ZoneChoice>(() => readZoneChoice(store));
35
+ const selection = useMemo<ZoneSelection>(
36
+ () => ({
37
+ current: choice,
38
+ select: (next) => {
39
+ rememberZoneChoice(next, store);
40
+ setChoice(next);
41
+ },
42
+ }),
43
+ [choice, store],
44
+ );
45
+ return <ZoneContext value={selection}>{children}</ZoneContext>;
46
+ }
47
+
48
+ // `null` without a provider — not a stand-in value: the settings dialog asks exactly whether there
49
+ // is anything to choose at all (the same rule `useTheme` follows).
50
+ export function useTimeZone(): ZoneSelection | null {
51
+ return useContext(ZoneContext);
52
+ }
53
+
54
+ /**
55
+ * The formatter every surface showing a time uses.
56
+ *
57
+ * ⚠️ A hook rather than a function with parameters: otherwise every cell would have to fetch locale
58
+ * AND zone itself, and the first one that forgets shows the device's zone again — which is the state
59
+ * before #467, only harder to find.
60
+ */
61
+ export function useDateTime(): { at: (value: string) => string; on: (value: string) => string } {
62
+ const i18n = useI18n();
63
+ const zone = useContext(ZoneContext);
64
+ const choice = zone?.current ?? DeviceZone;
65
+ return useMemo(
66
+ () => ({
67
+ at: (value: string) => formatDateTime(value, i18n.locale, choice),
68
+ on: (value: string) => formatDate(value, i18n.locale, choice),
69
+ }),
70
+ [i18n.locale, choice],
71
+ );
72
+ }
@@ -0,0 +1,140 @@
1
+ // The ONE place a timestamp becomes a readable sentence (#467).
2
+ //
3
+ // Every surface used to call `toLocaleString(locale)` for itself, which silently takes the DEVICE's
4
+ // zone. Three things follow that are not true:
5
+ //
6
+ // 1. Whoever travels, or uses a device in another zone, reads different times for the same
7
+ // events. For a record of what happened that is where it stops being evidence.
8
+ // 2. Nowhere does it say WHICH zone applies. A time without a zone is a number, not a statement —
9
+ // and it looks exactly like a real one.
10
+ // 3. Two people looking at the same entry see different times and cannot refer to one in
11
+ // conversation.
12
+ //
13
+ // ⚠️ **The zone is a LENS for reading, never a change to the value.** What is stored and sent stays
14
+ // UTC; this module only formats.
15
+ //
16
+ // ⚠️ The comment in `settings-dialog.tsx` is what this file answers: the timezone left with the
17
+ // Agents (#388) because nothing read it, and "a preference that changes nothing on screen is worse
18
+ // than none". This exists BEFORE the preference does, and that order is the whole of #467.
19
+
20
+ export interface TimeZoneStore {
21
+ getItem(key: string): string | null;
22
+ setItem(key: string, value: string): void;
23
+ }
24
+
25
+ const StorageKey = "intel.timezone";
26
+
27
+ // "This device's zone" as an explicit choice rather than an empty field: an empty field would look
28
+ // as if nothing were set, and invite somebody to set what already holds.
29
+ export const DeviceZone = "device";
30
+ export type ZoneChoice = string;
31
+
32
+ // ⚠️ Falling back to UTC rather than throwing: in an environment without Intl a time in UTC is
33
+ // still a time, while a thrown exception is an empty screen.
34
+ export function deviceZone(): string {
35
+ try {
36
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
37
+ } catch {
38
+ return "UTC";
39
+ }
40
+ }
41
+
42
+ // The stored value comes out of `localStorage` and may have been edited by hand, written by an older
43
+ // build, or name a zone this runtime does not know.
44
+ export function isKnownZone(zone: string): boolean {
45
+ if (zone === DeviceZone) return true;
46
+ try {
47
+ new Intl.DateTimeFormat("en-US", { timeZone: zone });
48
+ return true;
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+
54
+ export function readZoneChoice(store?: TimeZoneStore): ZoneChoice {
55
+ try {
56
+ const stored = store?.getItem(StorageKey);
57
+ return stored && isKnownZone(stored) ? stored : DeviceZone;
58
+ } catch {
59
+ return DeviceZone;
60
+ }
61
+ }
62
+
63
+ export function rememberZoneChoice(choice: ZoneChoice, store?: TimeZoneStore): void {
64
+ try {
65
+ store?.setItem(StorageKey, choice);
66
+ } catch {
67
+ // Without storage the choice holds for this session. Less than the reader wanted, more than an
68
+ // error they cannot switch off.
69
+ }
70
+ }
71
+
72
+ export function resolveZone(choice: ZoneChoice): string {
73
+ return choice === DeviceZone || !isKnownZone(choice) ? deviceZone() : choice;
74
+ }
75
+
76
+ /**
77
+ * A timestamp as a readable sentence — WITH its zone.
78
+ *
79
+ * ⚠️ The abbreviation is not decoration: a shifted time without one is precisely the silent
80
+ * misstatement this ticket is written against. And it comes from the runtime rather than from a
81
+ * string in the code — the runtime knows daylight saving, an appended label would be wrong for half
82
+ * the year.
83
+ *
84
+ * ⚠️ Individual components rather than `dateStyle`/`timeStyle`: Intl refuses the two style
85
+ * shorthands TOGETHER with `timeZoneName` and throws `Invalid option` — inside a table cell that
86
+ * takes the whole list with it. Found the hard way in `anchrd/gate#286`, where it read as "the row
87
+ * is missing" rather than as a formatting error.
88
+ */
89
+ export function formatDateTime(value: string, locale: string, choice: ZoneChoice): string {
90
+ const date = new Date(value);
91
+ // An unreadable timestamp travels through rather than rendering as "Invalid Date": the raw value
92
+ // at least says what stood there.
93
+ if (Number.isNaN(date.getTime())) return value;
94
+ return date.toLocaleString(locale, {
95
+ timeZone: resolveZone(choice),
96
+ year: "numeric",
97
+ month: "short",
98
+ day: "2-digit",
99
+ hour: "2-digit",
100
+ minute: "2-digit",
101
+ timeZoneName: "short",
102
+ });
103
+ }
104
+
105
+ /**
106
+ * The date alone, for the column that only ever showed one (the folder table's "Changed").
107
+ *
108
+ * ⚠️ It still resolves through the chosen zone, and that is the point rather than a detail: near
109
+ * midnight a date is exactly the value that differs between two zones, and a bare date carries no
110
+ * abbreviation to warn anybody.
111
+ */
112
+ export function formatDate(value: string, locale: string, choice: ZoneChoice): string {
113
+ const date = new Date(value);
114
+ if (Number.isNaN(date.getTime())) return value;
115
+ return date.toLocaleDateString(locale, { timeZone: resolveZone(choice) });
116
+ }
117
+
118
+ // ⚠️ A short curated list rather than `Intl.supportedValuesOf("timeZone")` — that is over 400
119
+ // entries, and a dropdown of 400 rows without a search is not a choice but an imposition. A zone
120
+ // missing here is added once it has been missed three times; until then the device's zone is the
121
+ // answer, and it is the right one for almost everybody. Ordered by offset, so the list reads like a
122
+ // map.
123
+ export const ZoneOptions: readonly string[] = [
124
+ "UTC",
125
+ "Europe/Lisbon",
126
+ "Europe/Berlin",
127
+ "Europe/Athens",
128
+ "Europe/Moscow",
129
+ "Asia/Dubai",
130
+ "Asia/Kolkata",
131
+ "Asia/Singapore",
132
+ "Asia/Tokyo",
133
+ "Australia/Sydney",
134
+ "Pacific/Auckland",
135
+ "America/Sao_Paulo",
136
+ "America/New_York",
137
+ "America/Chicago",
138
+ "America/Denver",
139
+ "America/Los_Angeles",
140
+ ];
@@ -1,102 +0,0 @@
1
- import { ChevronRight, MoreHorizontal } from "lucide-react";
2
- import { Slot } from "radix-ui";
3
- import type * as React from "react";
4
-
5
- import { cn } from "@/lib/utils";
6
-
7
- function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
8
- return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
9
- }
10
-
11
- function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
12
- return (
13
- <ol
14
- data-slot="breadcrumb-list"
15
- className={cn(
16
- "flex flex-wrap items-center gap-1.5 text-sm break-words text-muted-foreground sm:gap-2.5",
17
- className,
18
- )}
19
- {...props}
20
- />
21
- );
22
- }
23
-
24
- function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
25
- return (
26
- <li
27
- data-slot="breadcrumb-item"
28
- className={cn("inline-flex items-center gap-1.5", className)}
29
- {...props}
30
- />
31
- );
32
- }
33
-
34
- function BreadcrumbLink({
35
- asChild,
36
- className,
37
- ...props
38
- }: React.ComponentProps<"a"> & {
39
- asChild?: boolean;
40
- }) {
41
- const Comp = asChild ? Slot.Root : "a";
42
-
43
- return (
44
- <Comp
45
- data-slot="breadcrumb-link"
46
- className={cn("transition-colors hover:text-foreground", className)}
47
- {...props}
48
- />
49
- );
50
- }
51
-
52
- function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
53
- return (
54
- <span
55
- data-slot="breadcrumb-page"
56
- role="link"
57
- aria-disabled="true"
58
- aria-current="page"
59
- className={cn("font-normal text-foreground", className)}
60
- {...props}
61
- />
62
- );
63
- }
64
-
65
- function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<"li">) {
66
- return (
67
- <li
68
- data-slot="breadcrumb-separator"
69
- role="presentation"
70
- aria-hidden="true"
71
- className={cn("[&>svg]:size-3.5", className)}
72
- {...props}
73
- >
74
- {children ?? <ChevronRight />}
75
- </li>
76
- );
77
- }
78
-
79
- function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<"span">) {
80
- return (
81
- <span
82
- data-slot="breadcrumb-ellipsis"
83
- role="presentation"
84
- aria-hidden="true"
85
- className={cn("flex size-9 items-center justify-center", className)}
86
- {...props}
87
- >
88
- <MoreHorizontal className="size-4" />
89
- <span className="sr-only">More</span>
90
- </span>
91
- );
92
- }
93
-
94
- export {
95
- Breadcrumb,
96
- BreadcrumbEllipsis,
97
- BreadcrumbItem,
98
- BreadcrumbLink,
99
- BreadcrumbList,
100
- BreadcrumbPage,
101
- BreadcrumbSeparator,
102
- };