@remit/web-client 0.0.10 → 0.0.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.10",
3
+ "version": "0.0.12",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -6,7 +6,7 @@ import {
6
6
  configOperationsGetConfigQueryKey,
7
7
  } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
8
8
  import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
9
- import { Button, Input, Select, securityToApi } from "@remit/ui";
9
+ import { Button, Input, Select, SlidePanel, securityToApi } from "@remit/ui";
10
10
  import { useMutation, useQueryClient } from "@tanstack/react-query";
11
11
  import { Check, Loader2, X } from "lucide-react";
12
12
  import { useCallback, useEffect, useRef, useState } from "react";
@@ -19,7 +19,6 @@ import {
19
19
  type ProviderPreset,
20
20
  } from "../../lib/provider-presets.js";
21
21
  import { cn } from "../../lib/utils";
22
- import { SlidePanel } from "../ui/SlidePanel";
23
22
  import {
24
23
  appendAppPasswordHint,
25
24
  computeSmtpAutoFill,
package/src/index.css CHANGED
@@ -4,9 +4,18 @@
4
4
  the .dark ancestor scope used everywhere in this app. */
5
5
  @import "@remit/ui/tokens.css";
6
6
 
7
- /* Tailwind v4 content scanning: point at the source Tailwind must inspect
8
- for utility class usage (auto-detection misses linked workspace packages). */
7
+ /* Tailwind v4 content scanning: name every source Tailwind must inspect for
8
+ utility class usage. Automatic detection is rooted at the Vite root, which
9
+ for the distributor harness build is a throwaway directory holding only the
10
+ entry — so nothing here may rely on it. Both this package's own components
11
+ and the linked workspace UI package are declared explicitly.
12
+
13
+ Tests are excluded: they assert on class strings, and a scanned assertion
14
+ emits the utility it names into the shipped stylesheet. */
15
+ @source "./**/*.{ts,tsx}";
9
16
  @source "../../ui/src/**/*.{ts,tsx}";
17
+ @source not "./**/*.test.{ts,tsx}";
18
+ @source not "../../ui/src/**/*.test.{ts,tsx}";
10
19
 
11
20
  /* Hide scrollbars while maintaining scroll functionality */
12
21
  .overflow-y-auto {
@@ -0,0 +1,97 @@
1
+ import assert from "node:assert/strict";
2
+ import { readdirSync, readFileSync } from "node:fs";
3
+ import { dirname, relative, resolve, sep } from "node:path";
4
+ import { describe, it } from "node:test";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ /**
8
+ * Tailwind v4 roots automatic source detection at the bundler's root. The
9
+ * distributor harness builds from a throwaway root holding only the entry, so
10
+ * nothing this app renders is discovered automatically: every directory whose
11
+ * components carry utility classes has to be named by an `@source` rule. When
12
+ * one is missing the build still succeeds — it just ships a stylesheet with
13
+ * those utilities absent, and the screens using them lay out as unstyled boxes
14
+ * (#57: the settings slide-over covered the whole viewport).
15
+ */
16
+
17
+ const here = dirname(fileURLToPath(import.meta.url));
18
+ const cssPath = resolve(here, "index.css");
19
+ const css = readFileSync(cssPath, "utf8");
20
+
21
+ /** Globs Tailwind scans, and globs it is told to skip. */
22
+ const globs = (kind: "include" | "exclude"): string[] =>
23
+ [...css.matchAll(/@source\s+(not\s+)?"([^"]+)"/g)]
24
+ .filter(([, negated]) => (kind === "exclude") === Boolean(negated))
25
+ .map(([, , glob]) => glob);
26
+
27
+ /** The directory an `@source` glob starts scanning from. */
28
+ const sourceRoots = (): string[] =>
29
+ globs("include").map((glob) => {
30
+ const literal = glob.split("*")[0] ?? glob;
31
+ return resolve(dirname(cssPath), literal);
32
+ });
33
+
34
+ /** Every directory below `root` that holds a component file. */
35
+ const componentDirs = (root: string): string[] => {
36
+ const found: string[] = [];
37
+ const walk = (dir: string): void => {
38
+ const entries = readdirSync(dir, { withFileTypes: true });
39
+ if (entries.some((e) => e.isFile() && e.name.endsWith(".tsx"))) {
40
+ found.push(dir);
41
+ }
42
+ for (const entry of entries) {
43
+ if (entry.isDirectory() && entry.name !== "node_modules") {
44
+ walk(resolve(dir, entry.name));
45
+ }
46
+ }
47
+ };
48
+ walk(root);
49
+ return found;
50
+ };
51
+
52
+ const isCovered = (dir: string, roots: string[]): boolean =>
53
+ roots.some((root) => {
54
+ const rel = relative(root, dir);
55
+ return rel === "" || (!rel.startsWith("..") && !rel.startsWith(sep));
56
+ });
57
+
58
+ describe("tailwind source coverage (#57)", () => {
59
+ const roots = sourceRoots();
60
+
61
+ it("declares at least one source", () => {
62
+ assert.ok(roots.length > 0);
63
+ });
64
+
65
+ it("covers every directory in this package that renders components", () => {
66
+ const uncovered = componentDirs(here).filter((d) => !isCovered(d, roots));
67
+ assert.deepEqual(
68
+ uncovered.map((d) => relative(here, d)),
69
+ [],
70
+ );
71
+ });
72
+
73
+ it("covers the linked design system package", () => {
74
+ const ui = resolve(here, "../../ui/src");
75
+ const uncovered = componentDirs(ui).filter((d) => !isCovered(d, roots));
76
+ assert.deepEqual(
77
+ uncovered.map((d) => relative(ui, d)),
78
+ [],
79
+ );
80
+ });
81
+
82
+ /**
83
+ * A test asserting on a class string is a class string in a scanned file, so
84
+ * without this every utility a test names is emitted into the shipped CSS.
85
+ */
86
+ it("excludes test files from every source it scans", () => {
87
+ const uncovered = globs("include").filter(
88
+ (glob) =>
89
+ !globs("exclude").some(
90
+ (excluded) =>
91
+ excluded ===
92
+ glob.replace(/\/\*\*\/\*\.\{ts,tsx\}$/, "/**/*.test.{ts,tsx}"),
93
+ ),
94
+ );
95
+ assert.deepEqual(uncovered, []);
96
+ });
97
+ });
@@ -145,11 +145,26 @@ describe("groupBriefSections", () => {
145
145
 
146
146
  // --- Fallback to personal ---
147
147
 
148
- test("missing category falls back to personal", () => {
148
+ test("missing category lands in its own Unclassified section", () => {
149
+ // Never folded into Personal: unclassified mail is work the classifier
150
+ // has not done, and hiding it inside Personal is what made issue #45
151
+ // look like a working classifier with a huge personal inbox.
149
152
  const r = row({ id: "1" });
150
153
  const sections = groupBriefSections([r]);
151
154
  assert.strictEqual(sections.length, 1);
152
- assert.strictEqual(sections[0].id, "personal");
155
+ assert.strictEqual(sections[0].id, "uncategorized");
156
+ });
157
+
158
+ test("uncategorized rows do not inflate the personal section", () => {
159
+ const sections = groupBriefSections([
160
+ row({ id: "1", category: "personal" }),
161
+ row({ id: "2", category: "uncategorized" }),
162
+ row({ id: "3", category: "uncategorized" }),
163
+ ]);
164
+ const personal = sections.find((s) => s.id === "personal");
165
+ const unclassified = sections.find((s) => s.id === "uncategorized");
166
+ assert.strictEqual(personal?.threads.length, 1);
167
+ assert.strictEqual(unclassified?.threads.length, 2);
153
168
  });
154
169
 
155
170
  // --- Starred is a row marker, not a section (Flagged lives in the nav) ---
package/src/lib/brief.ts CHANGED
@@ -10,9 +10,12 @@
10
10
  * 4. Marketing
11
11
  * 5. Social
12
12
  * 6. Automated
13
+ * 7. Unclassified
13
14
  *
14
15
  * Each row lands in the section for its category; a row with no category counts
15
- * as `personal` (the classifier's own fallback). Starred mail is not a section —
16
+ * as `uncategorized`, which is its own section rather than being folded into
17
+ * Personal — unclassified mail is missing work, not a decision (issue #45).
18
+ * Starred mail is not a section —
16
19
  * the star is a per-row marker, so a starred message stays in its category.
17
20
  *
18
21
  * Sender trust (vip/wellknown) no longer sections the brief — the signal is
@@ -87,6 +90,11 @@ const CATEGORY_SECTIONS: ReadonlyArray<{
87
90
  { id: "marketing", label: "Marketing", category: MessageCategory.marketing },
88
91
  { id: "social", label: "Social", category: MessageCategory.social },
89
92
  { id: "automated", label: "Automated", category: MessageCategory.automated },
93
+ {
94
+ id: "uncategorized",
95
+ label: "Unclassified",
96
+ category: MessageCategory.uncategorized,
97
+ },
90
98
  ];
91
99
 
92
100
  /**
@@ -104,9 +112,9 @@ export function groupBriefSections(rows: ThreadRowData[]): ThreadSection[] {
104
112
  );
105
113
 
106
114
  for (const row of rows) {
107
- const category = row.category ?? MessageCategory.personal;
115
+ const category = row.category ?? MessageCategory.uncategorized;
108
116
  const bucket =
109
- byCategory.get(category) ?? byCategory.get(MessageCategory.personal);
117
+ byCategory.get(category) ?? byCategory.get(MessageCategory.uncategorized);
110
118
  bucket?.push(row);
111
119
  }
112
120
 
@@ -0,0 +1,29 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, test } from "node:test";
3
+ import { toDisplayCategory } from "./display-category.js";
4
+
5
+ describe("toDisplayCategory", () => {
6
+ test("shows an unclassified message as unclassified, not personal", () => {
7
+ // Collapsing `uncategorized` into `personal` made "the classifier never
8
+ // ran" indistinguishable from "the classifier decided this is personal"
9
+ // (issue #45).
10
+ assert.strictEqual(toDisplayCategory("uncategorized"), "uncategorized");
11
+ });
12
+
13
+ test("treats a row with no category as unclassified", () => {
14
+ assert.strictEqual(toDisplayCategory(undefined), "uncategorized");
15
+ });
16
+
17
+ test("passes every classified category through unchanged", () => {
18
+ for (const category of [
19
+ "personal",
20
+ "newsletter",
21
+ "marketing",
22
+ "automated",
23
+ "transactional",
24
+ "social",
25
+ ] as const) {
26
+ assert.strictEqual(toDisplayCategory(category), category);
27
+ }
28
+ });
29
+ });
@@ -5,16 +5,18 @@ import type { ThreadCategory } from "@remit/ui";
5
5
  * Map an API message category to a kit display category.
6
6
  *
7
7
  * The API category is total (RFC 032 Tier 2) and carries `uncategorized` for
8
- * messages that are metadata-synced but not yet body-classified. The kit's
9
- * display unions (`ThreadCategory`, `EmailRenderCategory`, badge `MessageCategory`)
10
- * have no `uncategorized` member there is nothing to show for "not yet
11
- * classified", so it collapses to `personal`, the classifier's own fallback and
12
- * the display default that never renders a badge. Pre-migration rows that still
13
- * read `undefined` collapse the same way.
8
+ * messages that are metadata-synced but not yet body-classified. That state is
9
+ * shown as itself, not collapsed into `personal`: collapsing made an
10
+ * unclassified message identical on screen to one the classifier positively
11
+ * decided was personal, so a classification gap read as a large personal inbox
12
+ * rather than as missing work (issue #45).
13
+ *
14
+ * Pre-migration rows that read `undefined` carry no category either, so they
15
+ * map to `uncategorized` the same way.
14
16
  */
15
17
  export function toDisplayCategory(
16
18
  category: RemitImapMessageCategory | undefined,
17
19
  ): ThreadCategory {
18
- if (category === undefined || category === "uncategorized") return "personal";
20
+ if (category === undefined) return "uncategorized";
19
21
  return category;
20
22
  }
@@ -1,72 +0,0 @@
1
- import { X } from "lucide-react";
2
- import type { ReactNode } from "react";
3
- import { cn } from "../../lib/utils";
4
-
5
- interface SlidePanelProps {
6
- isOpen: boolean;
7
- onClose: () => void;
8
- title: string;
9
- children: ReactNode;
10
- footer?: ReactNode;
11
- }
12
-
13
- export const SlidePanel = ({
14
- isOpen,
15
- onClose,
16
- title,
17
- children,
18
- footer,
19
- }: SlidePanelProps) => (
20
- <>
21
- {/* Backdrop overlay */}
22
- {/* biome-ignore lint/a11y/useSemanticElements: full-screen backdrop overlay; <button> default styles would break fixed inset positioning */}
23
- <div
24
- className={cn(
25
- "fixed inset-0 bg-black/30 z-40 transition-opacity",
26
- isOpen ? "opacity-100" : "opacity-0 pointer-events-none",
27
- )}
28
- onClick={onClose}
29
- onKeyDown={(e) => e.key === "Escape" && onClose()}
30
- role="button"
31
- tabIndex={0}
32
- aria-label="Close panel"
33
- />
34
-
35
- {/* Slide-in panel */}
36
- <div
37
- className={cn(
38
- "fixed top-0 right-0 h-full w-full sm:w-[400px] sm:max-w-[90vw] bg-canvas border-l border-line shadow-xl z-50",
39
- "transform transition-transform duration-200 ease-out",
40
- isOpen ? "translate-x-0" : "translate-x-full",
41
- )}
42
- role="dialog"
43
- aria-modal="true"
44
- aria-labelledby="slide-panel-title"
45
- >
46
- {/* Panel header */}
47
- <div className="flex items-center justify-between px-4 h-14 border-b border-line">
48
- <h2 id="slide-panel-title" className="font-semibold">
49
- {title}
50
- </h2>
51
- <button
52
- type="button"
53
- onClick={onClose}
54
- className="p-1.5 rounded-md hover:bg-surface-raised transition-colors"
55
- aria-label="Close"
56
- >
57
- <X className="size-5" />
58
- </button>
59
- </div>
60
-
61
- {/* Panel content */}
62
- <div className="h-[calc(100%-3.5rem)] flex flex-col">
63
- <div className="flex-1 overflow-auto p-4">{children}</div>
64
- {footer && (
65
- <div className="flex justify-end gap-3 p-4 border-t border-line bg-canvas">
66
- {footer}
67
- </div>
68
- )}
69
- </div>
70
- </div>
71
- </>
72
- );