@rebasepro/app 0.23.0 → 0.24.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.
@@ -8,7 +8,7 @@
8
8
  * here is the unauthenticated `/api/auth/config` probe used to detect
9
9
  * bootstrap mode and enabled providers before a session exists.
10
10
  */
11
- import type { AuthAdapterCapabilities } from "@rebasepro/types";
11
+ import type { AuthConfigDocument } from "@rebasepro/types";
12
12
  /**
13
13
  * What `GET /api/auth/config` answers.
14
14
  *
@@ -17,7 +17,7 @@ import type { AuthAdapterCapabilities } from "@rebasepro/types";
17
17
  * this one listed an `emailServiceEnabled` flag no backend sends, and marked as
18
18
  * optional fields every backend always returns.
19
19
  */
20
- export type AuthConfigResponse = AuthAdapterCapabilities;
20
+ export type AuthConfigResponse = AuthConfigDocument;
21
21
  /**
22
22
  * Cache container for `fetchAuthConfig` — holds both the inflight promise
23
23
  * (to deduplicate concurrent calls) and the cached result.
@@ -21,7 +21,14 @@ export type RebaseAuthController = AuthController & {
21
21
  /** Login with email and password */
22
22
  emailPasswordLogin: (email: string, password: string) => Promise<void>;
23
23
  /** Register a new user */
24
- register: (email: string, password: string, displayName?: string) => Promise<void>;
24
+ /**
25
+ * Create an account. `confirmationRequired` when the backend registers
26
+ * confirm-first (`requireEmailVerification`): nobody is signed in until
27
+ * the address is confirmed.
28
+ */
29
+ register: (email: string, password: string, displayName?: string) => Promise<{
30
+ confirmationRequired: boolean;
31
+ }>;
25
32
  /** Skip login (for anonymous access if enabled) */
26
33
  skipLogin: () => void;
27
34
  /** Whether login was skipped */
@@ -36,10 +43,33 @@ export type RebaseAuthController = AuthController & {
36
43
  forgotPassword: (email: string) => Promise<void>;
37
44
  /** Reset password using token from email */
38
45
  resetPassword: (token: string, password: string) => Promise<void>;
39
- /** Confirm an email address using the token from a verification email */
40
- verifyEmail: (token: string) => Promise<void>;
46
+ /**
47
+ * Confirm an email address using the token from a verification email.
48
+ * Refused with `PROOF_REQUIRED` when the account holds a password this
49
+ * call does not prove: pass `password` to keep it (and sign in), or
50
+ * `removeUnproven` to verify without it.
51
+ */
52
+ verifyEmail: (token: string, options?: {
53
+ password?: string;
54
+ removeUnproven?: boolean;
55
+ }) => Promise<{
56
+ passwordRemoved: boolean;
57
+ signedIn: boolean;
58
+ }>;
59
+ /** Sign in with a magic link's token. Absent when the client cannot. */
60
+ magicLinkLogin?: (token: string) => Promise<void>;
41
61
  /** Change password for authenticated user */
42
62
  changePassword: (oldPassword: string, newPassword: string) => Promise<void>;
63
+ /** Ask to move the signed-in account to another address. Absent when the client cannot. */
64
+ changeEmail?: (newEmail: string) => Promise<{
65
+ pendingEmail: string;
66
+ expiresAt: string;
67
+ }>;
68
+ /** Follow an address-change link. Absent when the client cannot. */
69
+ confirmEmailChange?: (token: string) => Promise<{
70
+ email: string;
71
+ removedProviders: string[];
72
+ }>;
43
73
  /** Update user profile */
44
74
  updateProfile: (displayName?: string, photoURL?: string) => Promise<User>;
45
75
  /** Fetch active sessions */
@@ -64,6 +94,8 @@ export type RebaseAuthController = AuthController & {
64
94
  * the client has no MFA support.
65
95
  */
66
96
  verifyMfaChallenge?: (mfaToken: string, challengeId: string, code: string) => Promise<void>;
97
+ /** The scopes the signed-in user holds; `undefined` until known. */
98
+ heldScopes?: string[];
67
99
  };
68
100
  /**
69
101
  * Structural type for the subset of `client.auth` (from `@rebasepro/client`)
@@ -91,8 +123,23 @@ export interface ClientAuth {
91
123
  resetPasswordForEmail(email: string): Promise<unknown>;
92
124
  resetPassword(token: string, password: string): Promise<unknown>;
93
125
  /** Optional so a hand-built auth client need not implement it. */
94
- verifyEmail?(token: string): Promise<unknown>;
126
+ verifyEmail?(token: string, options?: {
127
+ password?: string;
128
+ removeUnproven?: boolean;
129
+ }): Promise<unknown>;
95
130
  changePassword(oldPassword: string, newPassword: string): Promise<unknown>;
131
+ /** Optional so a hand-built auth client need not implement it. */
132
+ verifyMagicLink?(token: string): Promise<unknown>;
133
+ /** Optional so a hand-built auth client need not implement it. */
134
+ changeEmail?(newEmail: string): Promise<{
135
+ pendingEmail: string;
136
+ expiresAt: string;
137
+ }>;
138
+ /** Optional so a hand-built auth client need not implement it. */
139
+ confirmEmailChange?(token: string): Promise<{
140
+ email: string;
141
+ removedProviders: string[];
142
+ }>;
96
143
  updateUser(updates: {
97
144
  displayName?: string;
98
145
  photoURL?: string;
@@ -131,6 +178,12 @@ export interface RebaseAuthControllerProps {
131
178
  setAuthTokenGetter: (getter: () => Promise<string | null>) => void;
132
179
  };
133
180
  auth?: ClientAuth;
181
+ /** Read for the scopes the signed-in user holds (`GET /auth/scopes`). */
182
+ personalKeys?: {
183
+ listScopes(): Promise<{
184
+ held: string[];
185
+ }>;
186
+ };
134
187
  };
135
188
  /** Google OAuth client ID (optional, enables Google login) */
136
189
  googleClientId?: string;
@@ -55,3 +55,21 @@ export interface ResolveFilterOperatorsParams {
55
55
  * @group Models
56
56
  */
57
57
  export declare function resolveFilterOperators({ property, isArray, engine }: ResolveFilterOperatorsParams): WhereFilterOp[];
58
+ /**
59
+ * Whether a property can be filtered at all — the one answer the table header
60
+ * and the filters dialog both give.
61
+ *
62
+ * They used to answer it with a predicate each, and disagreed: the header
63
+ * allowed an array only of strings, numbers, dates, references and relations;
64
+ * the dialog allowed an array of anything, and listed an array of maps with an
65
+ * empty control. The header, for its part, ignored both the developer's
66
+ * `filterOperators: []` and a custom `admin.Filter`.
67
+ *
68
+ * A property with a custom filter field is filterable: the developer owns that
69
+ * field. Otherwise it is filterable when at least one operator survives
70
+ * {@link resolveFilterOperators} — for an array, on its item type. A tuple
71
+ * array (`of: [a, b]`) has no single item type to filter by.
72
+ *
73
+ * @group Models
74
+ */
75
+ export declare function isPropertyFilterable(property: Property, engine?: string): boolean;
@@ -13,8 +13,14 @@ export declare function resolveCollectionPathIds(path: string, allCollections: A
13
13
  */
14
14
  export declare function getCollectionBySlugWithin(slugOrPath: string, collections: AdminCollection[]): AdminCollection | undefined;
15
15
  /**
16
- * Get the subcollection combinations from a path:
17
- * "sites/es/locales" => ["sites/es/locales", "sites"]
16
+ * Every leading run of whole segments of a path, longest first: the
17
+ * candidates for the collection the path starts with.
18
+ * "sites/es/locales" => ["sites/es/locales", "sites/es", "sites"]
19
+ *
20
+ * Every length, not only the odd ones. A slug may contain slashes, so
21
+ * `content/podcasts/abc123/episodes` starts with the two-segment collection
22
+ * `content/podcasts`, and a path's own segment count says nothing about where
23
+ * its first collection ends.
18
24
  * @param subpaths
19
25
  */
20
26
  export declare function getCollectionPathsCombinations(subpaths: string[]): string[];
@@ -3,23 +3,20 @@
3
3
  *
4
4
  * These read a property's admin block — `readOnly`, `disabled.hidden`, the
5
5
  * declarative `conditions` — and lived in `@rebasepro/common`, which is on the
6
- * backend's dependency path. Nothing in core ever called them: `isReadOnly` and
7
- * `isHidden` are used only by the panel, and `applyPropertyConditions` has no
8
- * production caller at all.
6
+ * backend's dependency path. Nothing in core ever called them: `isReadOnly`,
7
+ * `isHidden` and `applyPropertyConditions` are the panel's alone.
9
8
  *
10
- * That last one is worth knowing about rather than assuming: the collection editor
11
- * has a whole Conditions UI, `serializable_utils` persists what it writes, and
12
- * `BaseProperty.conditions` documents itself as "evaluated at runtime like property
13
- * builders" — but the evaluator below is reached only from its own tests. It lives
14
- * here because here is where it would be called from once it is wired up.
9
+ * `applyPropertyConditions` is called by the record form, through
10
+ * `resolveFormProperty`, both where a field is laid out and where the form is
11
+ * judged on save — so a rule the collection editor writes hides, disables and
12
+ * requires what it says it does, and a field it hides is not held against the
13
+ * save. Until that was wired, the evaluator was reached only from its own tests.
15
14
  *
16
- * The one part of `conditions` that *is* applied is the literal case:
17
- * `hidden`/`readOnly`/`disabled` stated as a plain boolean rather than as a rule.
18
- * A literal needs no context, so `isHidden`/`isReadOnly`/`isDisabled` can answer
19
- * it directly, and those three gates are consulted everywhere a field is laid
20
- * out. A *rule* still is not evaluated anywhere in production — the split is
21
- * deliberate, not an oversight: it is the difference between a condition that
22
- * needs an entity to be evaluated against and one that does not.
15
+ * A condition may also be a literal: `hidden`/`readOnly`/`disabled` stated as a
16
+ * plain boolean rather than as a rule. A literal needs no context, so
17
+ * `isHidden`/`isReadOnly`/`isDisabled` answer it directly, and those three
18
+ * gates are consulted everywhere a field is laid out — including places with
19
+ * no entity to evaluate a rule against.
23
20
  */
24
21
  import type { ConditionContext, Property } from "@rebasepro/types";
25
22
  export declare function isReadOnly(property: Property): boolean;
@@ -0,0 +1,31 @@
1
+ import React from "react";
2
+ import type { RealtimeConnectionState } from "@rebasepro/types";
3
+ /**
4
+ * The realtime connection's state, re-rendering on every change.
5
+ *
6
+ * `undefined` when there is no client, or it was built without realtime —
7
+ * there is no connection to describe.
8
+ *
9
+ * @group Hooks and utilities
10
+ */
11
+ export declare function useRealtimeConnectionState(): RealtimeConnectionState | undefined;
12
+ export interface ConnectionLostBannerProps {
13
+ className?: string;
14
+ }
15
+ /**
16
+ * Says that live updates have stopped, while they have.
17
+ *
18
+ * Shown once the realtime client reports the connection `disconnected`: the
19
+ * socket has been down for longer than a blip, so tables, boards and open
20
+ * records are showing what they last received and nothing else will arrive
21
+ * until it is back. They keep their data rather than turning into errors — this
22
+ * banner is how the user learns it may be out of date. It disappears by itself
23
+ * when the connection returns, and every live view then refreshes.
24
+ *
25
+ * A brief drop (`reconnecting`) shows nothing: a deploy blips every socket for
26
+ * a second or two, and a banner that flashes on each one teaches people to
27
+ * ignore it.
28
+ *
29
+ * @group Components
30
+ */
31
+ export declare function ConnectionLostBanner({ className }: ConnectionLostBannerProps): React.JSX.Element | null;
@@ -3,9 +3,11 @@
3
3
  *
4
4
  * The server builds them from the configured frontend URL:
5
5
  * `<frontend>/reset-password?token=…` for a forgotten password, an admin's
6
- * "send reset email" and every invitation, and `<frontend>/verify-email?token=…`
7
- * to confirm an address. The frontend URL may carry a base path (`/admin`), so
8
- * the action is the *last* path segment, not the whole path.
6
+ * "send reset email" and every invitation, `<frontend>/verify-email?token=…`
7
+ * to confirm an address, `<frontend>/confirm-email-change?token=…` to move an
8
+ * account to a new one, and `<frontend>/auth/magic-link?token=…` to sign in.
9
+ * The frontend URL may carry a base path (`/admin`), so the action is read off
10
+ * the *end* of the path, not the whole path.
9
11
  */
10
12
  export type EmailLinkAction = {
11
13
  kind: "reset-password";
@@ -13,14 +15,21 @@ export type EmailLinkAction = {
13
15
  } | {
14
16
  kind: "verify-email";
15
17
  token: string;
18
+ } | {
19
+ kind: "confirm-email-change";
20
+ token: string;
21
+ } | {
22
+ kind: "magic-link";
23
+ token: string;
16
24
  };
17
25
  export declare function readEmailLinkAction(location: {
18
26
  pathname: string;
19
27
  search: string;
20
28
  }): EmailLinkAction | null;
21
29
  /**
22
- * Where the app lives: the link's own address without the action segment and
23
- * its token. `/admin/reset-password?token=…` becomes `/admin/`.
30
+ * Where the app lives: the link's own address without the action's path and
31
+ * its token. `/admin/reset-password?token=…` becomes `/admin/`, and
32
+ * `/admin/auth/magic-link?token=…` too.
24
33
  */
25
34
  export declare function appAddressOfEmailLink(location: {
26
35
  origin: string;
@@ -1,2 +1,4 @@
1
1
  export { LoginView } from "./LoginView.js";
2
2
  export type { LoginViewProps } from "./LoginView.js";
3
+ export { readEmailLinkAction } from "./email-link.js";
4
+ export type { EmailLinkAction } from "./email-link.js";
@@ -28,6 +28,19 @@ export interface UserSelectPopoverProps {
28
28
  * Whether users are currently loading.
29
29
  */
30
30
  loading?: boolean;
31
+ /**
32
+ * Called with the search text each time it changes (debounced), for a
33
+ * parent that searches its users on a server rather than passing them all
34
+ * in `users`. The list is still filtered here by the same text.
35
+ */
36
+ onSearchTextChange?: (searchText: string) => void;
37
+ /**
38
+ * For a parent that lists only the first page of a server search: how many
39
+ * users that search matched in all. Leave it out when `users` holds every
40
+ * match. When set, the header counts the listed users against it and a
41
+ * notice says the others are not listed.
42
+ */
43
+ totalCount?: number;
31
44
  /**
32
45
  * The current user (displayed as the "self" option at the top).
33
46
  */
@@ -60,4 +73,4 @@ export interface UserSelectPopoverProps {
60
73
  *
61
74
  * @group Components
62
75
  */
63
- export declare function UserSelectPopover({ selectedUser, onUserSelected, users, loading, currentUser, defaultLabel, renderLimit, className, size }: UserSelectPopoverProps): React.JSX.Element;
76
+ export declare function UserSelectPopover({ selectedUser, onUserSelected, users, loading, onSearchTextChange, totalCount, currentUser, defaultLabel, renderLimit, className, size }: UserSelectPopoverProps): React.JSX.Element;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * What the settings screen and the link's screen say about a refused address
3
+ * change: the codes a person can act on get the panel's own, translated words,
4
+ * and anything else keeps the server's message.
5
+ */
6
+ export declare function emailChangeErrorMessage(error: unknown, t: (key: string) => string): string;
@@ -13,3 +13,4 @@ export * from "./UserDisplay.js";
13
13
  export * from "./LoginView/index.js";
14
14
  export * from "./RebaseAuth.js";
15
15
  export * from "./SchemaDriftBanner.js";
16
+ export * from "./ConnectionLostBanner.js";
@@ -5,6 +5,12 @@ import React from "react";
5
5
  export type CollectionResolver = (slug: string) => {
6
6
  properties?: Record<string, unknown>;
7
7
  } | undefined;
8
+ /**
9
+ * Translate the path a collection is addressed by into the path its driver
10
+ * stores it under — `fs_diagnosis` into `diagnosis`, for a Firestore collection
11
+ * that declares `path: "diagnosis"`. See `EntityDataOptions.resolveDataPath`.
12
+ */
13
+ export type DataPathResolver = (path: string) => string | undefined;
8
14
  /**
9
15
  * Lets the layer that owns the collections hand a resolver *up* to the data
10
16
  * layer, which needs a collection's primary keys to give its rows an address.
@@ -15,9 +21,13 @@ export type CollectionResolver = (slug: string) => {
15
21
  * prop without giving every headless BaaS app a collections argument it has no
16
22
  * use for.
17
23
  *
24
+ * The same layer knows where each collection is stored, so it hands up the
25
+ * data-path translation alongside: a direct driver has to be given a
26
+ * collection's declared `path`, and only the registry knows it.
27
+ *
18
28
  * Registration is a ref write, so it is safe to call during render — and it has
19
29
  * to be. The views that fetch rows are *below* the registrar, and child effects
20
30
  * run before parent effects, so registering in an effect would bind after the
21
31
  * first page of rows had already been converted.
22
32
  */
23
- export declare const CollectionResolverRegistrationContext: React.Context<(resolver: CollectionResolver | undefined) => void>;
33
+ export declare const CollectionResolverRegistrationContext: React.Context<(resolver: CollectionResolver | undefined, resolveDataPath?: DataPathResolver) => void>;
package/dist/debug.js CHANGED
@@ -77,11 +77,11 @@ var UIStyleGuide = () => {
77
77
  /* @__PURE__ */ jsx(Typography, { variant: "h4", gutterBottom: true, className: "border-b pb-2 mb-6", children: "Icon Buttons" }),
78
78
  /* @__PURE__ */ jsx(Paper, { className: "p-6", children: /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap gap-8 items-center", children: [
79
79
  /* @__PURE__ */ jsxs("div", { className: "flex flex-col items-center gap-2", children: [
80
- /* @__PURE__ */ jsx(IconButton, { color: "primary", children: /* @__PURE__ */ jsx(IconExample, {}) }),
80
+ /* @__PURE__ */ jsx(IconButton, { children: /* @__PURE__ */ jsx(IconExample, {}) }),
81
81
  /* @__PURE__ */ jsx("span", { className: "text-[10px] text-text-disabled font-mono", children: "primary" })
82
82
  ] }),
83
83
  /* @__PURE__ */ jsxs("div", { className: "flex flex-col items-center gap-2", children: [
84
- /* @__PURE__ */ jsx(IconButton, { color: "secondary", children: /* @__PURE__ */ jsx(IconExample, {}) }),
84
+ /* @__PURE__ */ jsx(IconButton, { children: /* @__PURE__ */ jsx(IconExample, {}) }),
85
85
  /* @__PURE__ */ jsx("span", { className: "text-[10px] text-text-disabled font-mono", children: "secondary" })
86
86
  ] }),
87
87
  /* @__PURE__ */ jsxs("div", { className: "flex flex-col items-center gap-2", children: [
@@ -93,7 +93,7 @@ var UIStyleGuide = () => {
93
93
  /* @__PURE__ */ jsx("span", { className: "text-[10px] text-text-disabled font-mono", children: "disabled" })
94
94
  ] }),
95
95
  /* @__PURE__ */ jsxs("div", { className: "flex flex-col items-center gap-2", children: [
96
- /* @__PURE__ */ jsx(IconButton, { size: "small", color: "error", children: /* @__PURE__ */ jsx(Trash2Icon, {}) }),
96
+ /* @__PURE__ */ jsx(IconButton, { size: "small", children: /* @__PURE__ */ jsx(Trash2Icon, {}) }),
97
97
  /* @__PURE__ */ jsx("span", { className: "text-[10px] text-text-disabled font-mono", children: "delete" })
98
98
  ] })
99
99
  ] }) })
@@ -397,7 +397,7 @@ function PipelineOverview({
397
397
  }
398
398
  )
399
399
  ] }),
400
- /* @__PURE__ */ jsx4(
400
+ /* @__PURE__ */ jsxs4(
401
401
  Button2,
402
402
  {
403
403
  variant: "text",
@@ -405,8 +405,10 @@ function PipelineOverview({
405
405
  onClick: () => {
406
406
  onNavigate("/c/engagements");
407
407
  },
408
- endIcon: /* @__PURE__ */ jsx4(ChevronRightIcon, { className: "h-4 w-4" }),
409
- children: "View All"
408
+ children: [
409
+ "View All",
410
+ /* @__PURE__ */ jsx4(ChevronRightIcon, { className: "h-4 w-4" })
411
+ ]
410
412
  }
411
413
  )
412
414
  ] }),
@@ -1131,7 +1133,7 @@ function CompletedSection({
1131
1133
  if (willExpand) onExpand();
1132
1134
  };
1133
1135
  return /* @__PURE__ */ jsxs7("div", { children: [
1134
- /* @__PURE__ */ jsx7(
1136
+ /* @__PURE__ */ jsxs7(
1135
1137
  Button5,
1136
1138
  {
1137
1139
  variant: "text",
@@ -1139,34 +1141,36 @@ function CompletedSection({
1139
1141
  color: "neutral",
1140
1142
  className: "w-full justify-between hover:bg-surface-hover px-3 py-1.5 h-auto text-left",
1141
1143
  onClick: handleToggle,
1142
- endIcon: /* @__PURE__ */ jsx7(
1143
- "svg",
1144
- {
1145
- className: cls4(
1146
- "h-3 w-3 text-text-secondary dark:text-text-secondary-dark transition-transform duration-150",
1147
- expanded && "rotate-90"
1148
- ),
1149
- viewBox: "0 0 24 24",
1150
- fill: "none",
1151
- stroke: "currentColor",
1152
- strokeWidth: 2,
1153
- strokeLinecap: "round",
1154
- strokeLinejoin: "round",
1155
- children: /* @__PURE__ */ jsx7("polyline", { points: "9 18 15 12 9 6" })
1156
- }
1157
- ),
1158
- children: /* @__PURE__ */ jsxs7(
1159
- Typography6,
1160
- {
1161
- variant: "caption",
1162
- color: "secondary",
1163
- className: "font-semibold uppercase tracking-wider text-[10px]",
1164
- children: [
1165
- "Completed",
1166
- tasks.length > 0 ? ` (${tasks.length}${hasMore ? "+" : ""})` : ""
1167
- ]
1168
- }
1169
- )
1144
+ children: [
1145
+ /* @__PURE__ */ jsxs7(
1146
+ Typography6,
1147
+ {
1148
+ variant: "caption",
1149
+ color: "secondary",
1150
+ className: "font-semibold uppercase tracking-wider text-[10px]",
1151
+ children: [
1152
+ "Completed",
1153
+ tasks.length > 0 ? ` (${tasks.length}${hasMore ? "+" : ""})` : ""
1154
+ ]
1155
+ }
1156
+ ),
1157
+ /* @__PURE__ */ jsx7(
1158
+ "svg",
1159
+ {
1160
+ className: cls4(
1161
+ "h-3 w-3 text-text-secondary dark:text-text-secondary-dark transition-transform duration-150",
1162
+ expanded && "rotate-90"
1163
+ ),
1164
+ viewBox: "0 0 24 24",
1165
+ fill: "none",
1166
+ stroke: "currentColor",
1167
+ strokeWidth: 2,
1168
+ strokeLinecap: "round",
1169
+ strokeLinejoin: "round",
1170
+ children: /* @__PURE__ */ jsx7("polyline", { points: "9 18 15 12 9 6" })
1171
+ }
1172
+ )
1173
+ ]
1170
1174
  }
1171
1175
  ),
1172
1176
  expanded && /* @__PURE__ */ jsx7("div", { className: "mt-1 space-y-0.5", children: loading && tasks.length === 0 ? /* @__PURE__ */ jsxs7("div", { className: "flex items-center gap-2 px-3 py-3", children: [
@@ -1537,7 +1541,10 @@ function CalendarWidget({ loading, tasks, onOpenTask }) {
1537
1541
  {
1538
1542
  onClick: () => setSelectedDay(isSelected ? null : day.dateKey),
1539
1543
  className: cls5(
1540
- "flex flex-col items-center justify-center py-1.5 rounded-md transition-colors duration-100 focus:outline-none",
1544
+ // The today and selected rings are utilities, which outrank the kit's
1545
+ // base :focus-visible ring — so the focus ring is said here too, or a
1546
+ // keyboard user on today's cell sees nothing change.
1547
+ "flex flex-col items-center justify-center py-1.5 rounded-md transition-colors duration-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary",
1541
1548
  day.isCurrentMonth ? "text-text-primary dark:text-text-primary-dark" : "text-surface-300 dark:text-surface-600",
1542
1549
  day.isToday && !isSelected && "ring-1 ring-primary/50",
1543
1550
  isSelected ? "bg-primary/10 dark:bg-primary/15 ring-1 ring-primary/60" : "hover:bg-surface-hover",
@@ -2661,8 +2668,8 @@ function CollectionTableDemo() {
2661
2668
  /* @__PURE__ */ jsx11(TableCell, { children: /* @__PURE__ */ jsx11("div", { className: "flex flex-wrap gap-1", children: project.tags.map((tag) => /* @__PURE__ */ jsx11(Chip4, { size: "smallest", colorScheme: "cyan", children: tag }, tag)) }) }),
2662
2669
  /* @__PURE__ */ jsx11(TableCell, { children: /* @__PURE__ */ jsx11(Typography9, { variant: "body2", className: "font-mono text-xs", children: project.dueDate }) }),
2663
2670
  /* @__PURE__ */ jsx11(TableCell, { style: { width: "80px" }, children: /* @__PURE__ */ jsxs11("div", { className: "flex gap-1", children: [
2664
- /* @__PURE__ */ jsx11(Tooltip2, { title: "Edit", asChild: true, children: /* @__PURE__ */ jsx11(IconButton3, { size: "smallest", children: /* @__PURE__ */ jsx11(PencilIcon, { size: iconSize.smallest }) }) }),
2665
- /* @__PURE__ */ jsx11(Tooltip2, { title: "Delete", asChild: true, children: /* @__PURE__ */ jsx11(IconButton3, { size: "smallest", children: /* @__PURE__ */ jsx11(Trash2Icon2, { size: iconSize.smallest }) }) })
2671
+ /* @__PURE__ */ jsx11(Tooltip2, { title: "Edit", asChild: true, children: /* @__PURE__ */ jsx11(IconButton3, { "aria-label": "Edit", size: "smallest", children: /* @__PURE__ */ jsx11(PencilIcon, { size: iconSize.smallest }) }) }),
2672
+ /* @__PURE__ */ jsx11(Tooltip2, { title: "Delete", asChild: true, children: /* @__PURE__ */ jsx11(IconButton3, { "aria-label": "Delete", size: "smallest", children: /* @__PURE__ */ jsx11(Trash2Icon2, { size: iconSize.smallest }) }) })
2666
2673
  ] }) })
2667
2674
  ] }, project.id)) })
2668
2675
  ] }),
@@ -3569,7 +3576,7 @@ function UIReferenceView() {
3569
3576
  /* @__PURE__ */ jsx16("button", { className: cls12("px-3 py-1 text-xs font-medium rounded-md transition-all", "bg-surface-lifted shadow-sm text-text-primary dark:text-text-primary-dark"), children: "Content" }),
3570
3577
  /* @__PURE__ */ jsx16("button", { className: cls12("px-3 py-1 text-xs font-medium rounded-md transition-all", "text-surface-500 hover:text-surface-900 dark:hover:text-white"), children: "Studio" })
3571
3578
  ] }),
3572
- /* @__PURE__ */ jsxs16(Menu, { trigger: /* @__PURE__ */ jsx16(IconButton5, { color: "inherit", children: /* @__PURE__ */ jsx16(MoonIcon, {}) }), children: [
3579
+ /* @__PURE__ */ jsxs16(Menu, { trigger: /* @__PURE__ */ jsx16(IconButton5, { children: /* @__PURE__ */ jsx16(MoonIcon, {}) }), children: [
3573
3580
  /* @__PURE__ */ jsxs16(MenuItem, { children: [
3574
3581
  /* @__PURE__ */ jsx16(MoonIcon, { size: iconSize3.smallest }),
3575
3582
  " Dark"
@@ -4092,7 +4099,7 @@ function UIReferenceView() {
4092
4099
  roles: []
4093
4100
  }
4094
4101
  ].map((user) => /* @__PURE__ */ jsxs16(TableRow2, { children: [
4095
- /* @__PURE__ */ jsx16(TableCell2, { style: { width: "64px" }, children: /* @__PURE__ */ jsx16(Tooltip3, { asChild: true, title: "Delete this user", children: /* @__PURE__ */ jsx16(IconButton5, { size: "small", children: /* @__PURE__ */ jsx16(Trash2Icon3, {}) }) }) }),
4102
+ /* @__PURE__ */ jsx16(TableCell2, { style: { width: "64px" }, children: /* @__PURE__ */ jsx16(Tooltip3, { asChild: true, title: "Delete this user", children: /* @__PURE__ */ jsx16(IconButton5, { "aria-label": "Delete this user", size: "small", children: /* @__PURE__ */ jsx16(Trash2Icon3, {}) }) }) }),
4096
4103
  /* @__PURE__ */ jsx16(TableCell2, { children: user.email }),
4097
4104
  /* @__PURE__ */ jsx16(TableCell2, { className: "font-medium", children: user.displayName }),
4098
4105
  /* @__PURE__ */ jsx16(TableCell2, { children: /* @__PURE__ */ jsx16("div", { className: "flex flex-wrap gap-2", children: user.roles.map((role) => /* @__PURE__ */ jsx16(Chip8, { colorScheme: role.isAdmin ? "purple" : "blue", size: "small", children: role.name }, role.id)) }) })
@@ -0,0 +1,133 @@
1
+ import type { CollectionAccessor, Entity, FindParams } from "@rebasepro/types";
2
+ /**
3
+ * The part of a read that says *which* rows, without saying how many or from
4
+ * where. The window adds `limit` and `offset` itself, page by page.
5
+ */
6
+ export type CollectionWindowQuery<M extends Record<string, unknown>> = Omit<FindParams<M>, "limit" | "offset" | "page" | "after">;
7
+ /**
8
+ * What a {@link CollectionWindow} reports on every change.
9
+ */
10
+ export interface CollectionWindowState<M extends Record<string, unknown> = Record<string, unknown>> {
11
+ /**
12
+ * The rows in the window, in order, without duplicates.
13
+ *
14
+ * `undefined` until the window first covers what it was opened for — the
15
+ * first page, or every row a restored scroll position had on screen — so a
16
+ * view that is still showing its previous rows keeps them instead of
17
+ * flashing a partial list.
18
+ */
19
+ rows: Entity<M>[] | undefined;
20
+ /** Rows not yet on screen are being read: the first page, or the next one. */
21
+ loading: boolean;
22
+ error: Error | undefined;
23
+ /** A page came back short, so there is nothing past `rows`. */
24
+ complete: boolean;
25
+ }
26
+ export interface CollectionWindowOptions<M extends Record<string, unknown>> {
27
+ accessor: Pick<CollectionAccessor<M>, "find" | "listen">;
28
+ query: CollectionWindowQuery<M>;
29
+ /** Rows in the first page, which is the one kept live. */
30
+ pageSize: number;
31
+ /** How many rows the window should hold. `Infinity` reads them all. */
32
+ target: number;
33
+ /** Subscribe to the first page when the accessor can. Defaults to `true`. */
34
+ live?: boolean;
35
+ /**
36
+ * Applied to every page as it arrives, before it is merged — the panel's
37
+ * own `afterRead`, for instance.
38
+ */
39
+ process?: (rows: Entity<M>[]) => Promise<Entity<M>[]> | Entity<M>[];
40
+ /**
41
+ * When the subscription fails, read the first page once instead of
42
+ * reporting the failure (which is only reported if that read fails too).
43
+ */
44
+ fallbackToFind?: boolean;
45
+ /**
46
+ * Read the first page once if the subscription has not answered by then.
47
+ * The subscription still wins whenever it does answer.
48
+ */
49
+ firstPaintTimeoutMs?: number;
50
+ onChange: (state: CollectionWindowState<M>) => void;
51
+ }
52
+ /**
53
+ * A scrolled collection view's rows, read one page at a time.
54
+ *
55
+ * ## Why this exists
56
+ *
57
+ * The table, list, card and board views used to grow a single read: the next
58
+ * page was the same query with `limit` raised by a page, so page *k* re-read
59
+ * the *k − 1* pages before it, and the read that reached row 1,001 asked for a
60
+ * `limit` the API refuses (`MAX_LIST_LIMIT`). Every row past 1,000 was out of
61
+ * reach, behind a developer error. The board clamped at the ceiling instead,
62
+ * and its columns then said they were finished while their own header counted
63
+ * thousands more.
64
+ *
65
+ * ## How it reads
66
+ *
67
+ * - The **first page** is a subscription when the accessor can listen, else a
68
+ * one-shot read. It is the only live query the window holds.
69
+ * - Every **later page** is a one-shot read by `offset`, of only the rows past
70
+ * the ones already held, never more than `MAX_LIST_LIMIT` at a time. Offset
71
+ * rather than the keyset cursor because it is the one paging every sort a
72
+ * view offers can express — a relevance-ordered search has no cursor — and
73
+ * because the server breaks ties on the id, so pages neither repeat nor skip
74
+ * rows under a sort with duplicates.
75
+ * - **Realtime** covers every row held, not only the first page. The server
76
+ * re-runs a subscription on every write to its collection, so each push after
77
+ * the first says "something changed". The rows past the first page are then
78
+ * read again, in reads of at most `MAX_LIST_LIMIT`, and replaced in one step.
79
+ * Pushes that arrive while that re-read runs are folded into one more.
80
+ *
81
+ * Rows are de-duplicated by id: between a push moving the first page and the
82
+ * re-read of the rest, a row can briefly belong to both.
83
+ */
84
+ export declare class CollectionWindow<M extends Record<string, unknown>> {
85
+ private readonly accessor;
86
+ private readonly query;
87
+ private readonly pageSize;
88
+ private readonly options;
89
+ /** Rows the window must hold before it first shows any. */
90
+ private readonly holdUntil;
91
+ private target;
92
+ private head;
93
+ /** The first page came back full, so there may be more past it. */
94
+ private headFull;
95
+ /** Rows past the first page, from offset `pageSize` on. */
96
+ private tail;
97
+ /** The last read past the first page came back short. */
98
+ private tailEnded;
99
+ /** The first page changed after it first arrived: re-read the rest. */
100
+ private stale;
101
+ private pumping;
102
+ private loadingMore;
103
+ private headError;
104
+ private tailError;
105
+ private settled;
106
+ private disposed;
107
+ private headSequence;
108
+ private liveAnswered;
109
+ private unsubscribe;
110
+ private firstPaintTimer;
111
+ constructor(options: CollectionWindowOptions<M>);
112
+ start(): void;
113
+ /** Hold this many rows. Fewer than are held drops the rows past it. */
114
+ setTarget(target: number): void;
115
+ dispose(): void;
116
+ private readHeadOnce;
117
+ private receiveLiveError;
118
+ private receiveHead;
119
+ /** Rows still wanted past the ones held. */
120
+ private wanted;
121
+ /**
122
+ * The one loop that reads past the first page, so a re-read and a next page
123
+ * never run at the same time and interleave their offsets.
124
+ */
125
+ private pump;
126
+ private readNext;
127
+ /** Read every held row past the first page again, and swap them in at once. */
128
+ private reread;
129
+ private process;
130
+ private complete;
131
+ private emit;
132
+ private rows;
133
+ }
@@ -1,5 +1,6 @@
1
1
  export * from "./data/useData.js";
2
2
  export * from "./data/useCollection.js";
3
+ export * from "./data/collectionWindow.js";
3
4
  export * from "./data/useFetch.js";
4
5
  export * from "./data/useRelationSelector.js";
5
6
  export * from "./data/save.js";