diff --git a/.changeset/grumpy-eagles-mix.md b/.changeset/grumpy-eagles-mix.md
new file mode 100644
index 000000000..0a812b04b
--- /dev/null
+++ b/.changeset/grumpy-eagles-mix.md
@@ -0,0 +1,19 @@
+---
+"@lightsparkdev/core": minor
+---
+
+- **Test script**
+ - Renamed `test` to `test-cmd` and made `test` an alias that accepts arbitrary Jest patterns.
+- **Requester**
+ - Switched `wsClient` to a lazily initialized `Promise` resolved in a new `initWsClient` method.
+ - Moved `autoBind` into constructor and improved cleanup/cancellation logic in `subscribe()`.
+- **New tests**
+ - Added `Requester.test.ts` covering query execution, error handling, subscriptions, and signing logic.
+- **Errors utility**
+ - Enhanced `errorToJSON` to enumerate non-enumerable props and optionally stringify nested objects.
+- **LocalStorage utility**
+ - Made `getLocalStorageBoolean` return `true`/`false`/`null`; `getLocalStorageConfigItem` now defaults missing keys to `false`.
+- **Type guards**
+ - Tightened `isObject` signature, added `isRecord`, and removed a duplicate in `types.ts`.
+- **Static assets**
+ - Replaced the monolithic SVG logo with an optimized, higher-resolution `lightspark-logo.svg`.
diff --git a/.changeset/small-lies-perform.md b/.changeset/small-lies-perform.md
new file mode 100644
index 000000000..489af78e9
--- /dev/null
+++ b/.changeset/small-lies-perform.md
@@ -0,0 +1,36 @@
+---
+"@lightsparkdev/ui": patch
+---
+
+- **BirthdayInput**
+ - Dropped internal validity state; added `formatDateToText` hint formatter and “must be before today” validation.
+- **TextInput**
+ - Introduced `success` & `contentError` props, `hideNonErrorsIfBlurred`, configurable `iconOffsets`, `iconStrokeWidths`.
+- **PhoneInput**
+ - Added `onFocus` callback.
+- **QRCode**
+ - Swapped in new `LogoMark` asset (vs. `FramedLogoOnCircle`), adjusted image sizing.
+- **Drawer & Modal**
+ - Added `alignBottom` and `disableTouchMove` flags to support bottom-aligned, non-dismissable drawers.
+- **Button & Checkbox**
+ - New kinds: `green37`, `gray99`, `white21`.
+ - Added margin props (`mb`, `ml`, `mr`) for fine-grained spacing.
+- **CardForm**
+ - Major refactor: dozens of new props (`aboveHeaderContent`, `graphicHeader`, `centeredContent`, `paddingX`, `contentMarginTop`, `formButtonTopMargin`, `selectMarginTop`, `smDontAdjustWidth`, etc.).
+ - Extracted `CardFormHeadline`/`CenteredHeader`, wrapped forms in a `Flex` when using graphic headers.
+- **InputSubtext**
+ - Now supports rich React-node `content`, distinguishes error vs. success styling, and respects “hide if blurred” logic.
+- **Toasts**
+ - Added optional `type` (`error` | `success` | `info`) to color toast backgrounds.
+- **Hooks**
+ - **`useFields`**: validator signature now `(value, fields?)`; added `matchesField` and `clabe` validators; smarter merge to prevent unnecessary rerenders.
+ - **`useQueryParamBooleans`**: new hook for parsing multiple boolean query parameters at once.
+- **Icons**
+ - Introduced dozens of new icons (e.g. `LogoMark`, `LightningBoltOutline`, `NonagonCheckmark`, `NubankLogo`) plus a full “central” icon set under `icons/central/`.
+ - Standardized on a `PathProps` interface for configurable strokes.
+- **Styles & Theme**
+ - **Colors**: added `gray35`, `gray7`, `white21`, `green37`, `blue32`, `linkLight`.
+ - **Layout helper**: `buildStandardContentInset` gains an `smDontAdjustWidth` opt-out.
+ - Tweaked `smHeaderLogoMarginLeft` (30px → 20px).
+ - **Button themes**: added `white21`, `linkLight`, and `tertiary` kinds.
+ - **CardForm theme**: now allows zero padding for new layout options.
diff --git a/apps/examples/oauth-app/index.html b/apps/examples/oauth-app/index.html
index 917765e92..4238dd647 100644
--- a/apps/examples/oauth-app/index.html
+++ b/apps/examples/oauth-app/index.html
@@ -2,18 +2,21 @@
-
-
+ Lightspark OAuth Demo
-
-
-
- Lightspark OAuth Demo
+
+
+
+
+
+
+
+
diff --git a/apps/examples/oauth-app/public/android-chrome-192x192.png b/apps/examples/oauth-app/public/android-chrome-192x192.png
new file mode 100644
index 000000000..b2a3d0218
Binary files /dev/null and b/apps/examples/oauth-app/public/android-chrome-192x192.png differ
diff --git a/apps/examples/oauth-app/public/android-chrome-512x512.png b/apps/examples/oauth-app/public/android-chrome-512x512.png
new file mode 100644
index 000000000..ac0048c4f
Binary files /dev/null and b/apps/examples/oauth-app/public/android-chrome-512x512.png differ
diff --git a/apps/examples/oauth-app/public/apple-touch-icon.png b/apps/examples/oauth-app/public/apple-touch-icon.png
new file mode 100644
index 000000000..38cd2bb4d
Binary files /dev/null and b/apps/examples/oauth-app/public/apple-touch-icon.png differ
diff --git a/apps/examples/oauth-app/public/favicon-16x16.png b/apps/examples/oauth-app/public/favicon-16x16.png
new file mode 100644
index 000000000..1140dbc80
Binary files /dev/null and b/apps/examples/oauth-app/public/favicon-16x16.png differ
diff --git a/apps/examples/oauth-app/public/favicon-32x32.png b/apps/examples/oauth-app/public/favicon-32x32.png
new file mode 100644
index 000000000..13db9919e
Binary files /dev/null and b/apps/examples/oauth-app/public/favicon-32x32.png differ
diff --git a/apps/examples/oauth-app/public/favicon-48x48.png b/apps/examples/oauth-app/public/favicon-48x48.png
new file mode 100644
index 000000000..495863938
Binary files /dev/null and b/apps/examples/oauth-app/public/favicon-48x48.png differ
diff --git a/apps/examples/oauth-app/public/favicon.ico b/apps/examples/oauth-app/public/favicon.ico
index edecb5b07..39e5a82c4 100644
Binary files a/apps/examples/oauth-app/public/favicon.ico and b/apps/examples/oauth-app/public/favicon.ico differ
diff --git a/apps/examples/oauth-app/public/logo192.png b/apps/examples/oauth-app/public/logo192.png
deleted file mode 100644
index fc44b0a37..000000000
Binary files a/apps/examples/oauth-app/public/logo192.png and /dev/null differ
diff --git a/apps/examples/oauth-app/public/logo512.png b/apps/examples/oauth-app/public/logo512.png
deleted file mode 100644
index a4e47a654..000000000
Binary files a/apps/examples/oauth-app/public/logo512.png and /dev/null differ
diff --git a/apps/examples/oauth-app/public/manifest.json b/apps/examples/oauth-app/public/manifest.json
index 080d6c77a..e8e03397e 100644
--- a/apps/examples/oauth-app/public/manifest.json
+++ b/apps/examples/oauth-app/public/manifest.json
@@ -1,21 +1,36 @@
{
- "short_name": "React App",
- "name": "Create React App Sample",
+ "short_name": "Lightspark OAuth App",
+ "name": "Lightspark OAuth App",
"icons": [
{
- "src": "favicon.ico",
- "sizes": "64x64 32x32 24x24 16x16",
- "type": "image/x-icon"
+ "src": "/android-chrome-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ },
+ {
+ "src": "/android-chrome-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png"
+ },
+ {
+ "src": "/apple-touch-icon.png",
+ "sizes": "180x180",
+ "type": "image/png"
+ },
+ {
+ "src": "/favicon-32x32.png",
+ "sizes": "32x32",
+ "type": "image/png"
},
{
- "src": "logo192.png",
- "type": "image/png",
- "sizes": "192x192"
+ "src": "/favicon-16x16.png",
+ "sizes": "16x16",
+ "type": "image/png"
},
{
- "src": "logo512.png",
- "type": "image/png",
- "sizes": "512x512"
+ "src": "/favicon.ico",
+ "sizes": "16x16 32x32 48x48",
+ "type": "image/x-icon"
}
],
"start_url": ".",
diff --git a/apps/examples/ui-test-app/index.html b/apps/examples/ui-test-app/index.html
index 297ea69dd..88548ef34 100644
--- a/apps/examples/ui-test-app/index.html
+++ b/apps/examples/ui-test-app/index.html
@@ -2,24 +2,21 @@
-
-
-
+ Lightspark UI Test App
-
-
+
+
+
+
-
+
- Lightspark
+
diff --git a/apps/examples/ui-test-app/public/android-chrome-192x192.png b/apps/examples/ui-test-app/public/android-chrome-192x192.png
new file mode 100644
index 000000000..b2a3d0218
Binary files /dev/null and b/apps/examples/ui-test-app/public/android-chrome-192x192.png differ
diff --git a/apps/examples/ui-test-app/public/android-chrome-512x512.png b/apps/examples/ui-test-app/public/android-chrome-512x512.png
new file mode 100644
index 000000000..ac0048c4f
Binary files /dev/null and b/apps/examples/ui-test-app/public/android-chrome-512x512.png differ
diff --git a/apps/examples/ui-test-app/public/apple-touch-icon.png b/apps/examples/ui-test-app/public/apple-touch-icon.png
new file mode 100644
index 000000000..38cd2bb4d
Binary files /dev/null and b/apps/examples/ui-test-app/public/apple-touch-icon.png differ
diff --git a/apps/examples/ui-test-app/public/favicon-16.ico b/apps/examples/ui-test-app/public/favicon-16.ico
deleted file mode 100644
index a87e750ee..000000000
Binary files a/apps/examples/ui-test-app/public/favicon-16.ico and /dev/null differ
diff --git a/apps/examples/ui-test-app/public/favicon-16x16.png b/apps/examples/ui-test-app/public/favicon-16x16.png
new file mode 100644
index 000000000..1140dbc80
Binary files /dev/null and b/apps/examples/ui-test-app/public/favicon-16x16.png differ
diff --git a/apps/examples/ui-test-app/public/favicon-32.ico b/apps/examples/ui-test-app/public/favicon-32.ico
deleted file mode 100644
index edecb5b07..000000000
Binary files a/apps/examples/ui-test-app/public/favicon-32.ico and /dev/null differ
diff --git a/apps/examples/ui-test-app/public/favicon-32x32.png b/apps/examples/ui-test-app/public/favicon-32x32.png
new file mode 100644
index 000000000..13db9919e
Binary files /dev/null and b/apps/examples/ui-test-app/public/favicon-32x32.png differ
diff --git a/apps/examples/ui-test-app/public/favicon.ico b/apps/examples/ui-test-app/public/favicon.ico
new file mode 100644
index 000000000..39e5a82c4
Binary files /dev/null and b/apps/examples/ui-test-app/public/favicon.ico differ
diff --git a/apps/examples/ui-test-app/public/logo192.png b/apps/examples/ui-test-app/public/logo192.png
deleted file mode 100644
index c31b513a7..000000000
Binary files a/apps/examples/ui-test-app/public/logo192.png and /dev/null differ
diff --git a/apps/examples/ui-test-app/public/logo512.png b/apps/examples/ui-test-app/public/logo512.png
deleted file mode 100644
index 230379bde..000000000
Binary files a/apps/examples/ui-test-app/public/logo512.png and /dev/null differ
diff --git a/apps/examples/ui-test-app/public/manifest.json b/apps/examples/ui-test-app/public/manifest.json
index a931ae928..71c6059ef 100644
--- a/apps/examples/ui-test-app/public/manifest.json
+++ b/apps/examples/ui-test-app/public/manifest.json
@@ -1,21 +1,36 @@
{
- "short_name": "Lightspark",
- "name": "Lightspark App",
+ "short_name": "Lightspark UI Test App",
+ "name": "Lightspark UI Test App",
"icons": [
{
- "src": "favicon.ico",
- "sizes": "64x64 32x32 24x24 16x16",
- "type": "image/x-icon"
+ "src": "/android-chrome-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ },
+ {
+ "src": "/android-chrome-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png"
+ },
+ {
+ "src": "/apple-touch-icon.png",
+ "sizes": "180x180",
+ "type": "image/png"
+ },
+ {
+ "src": "/favicon-32x32.png",
+ "sizes": "32x32",
+ "type": "image/png"
},
{
- "src": "logo192.png",
- "type": "image/png",
- "sizes": "192x192"
+ "src": "/favicon-16x16.png",
+ "sizes": "16x16",
+ "type": "image/png"
},
{
- "src": "logo512.png",
- "type": "image/png",
- "sizes": "512x512"
+ "src": "/favicon.ico",
+ "sizes": "16x16 32x32 48x48",
+ "type": "image/x-icon"
}
],
"start_url": ".",
diff --git a/apps/examples/ui-test-app/src/tests/toReactNodes.test.tsx b/apps/examples/ui-test-app/src/tests/toReactNodes.test.tsx
index 90e7c9eb6..12f21fe7f 100644
--- a/apps/examples/ui-test-app/src/tests/toReactNodes.test.tsx
+++ b/apps/examples/ui-test-app/src/tests/toReactNodes.test.tsx
@@ -276,7 +276,7 @@ describe("toReactNodes", () => {
to: TestAppRoutes.PageOne,
},
};
- const iconNode = { icon: { name: "LogoBolt" as const } };
+ const iconNode = { icon: { name: "LogoMark" as const } };
const nextLinkNode = {
nextLink: {
text: "Some next link node that should have typography applied",
@@ -330,7 +330,7 @@ describe("toReactNodes", () => {
typography: { type: "Display", size: "Medium" } as const,
},
};
- const iconNode = { icon: { name: "LogoBolt" } as const };
+ const iconNode = { icon: { name: "LogoMark" } as const };
const externalLinkNode = {
link: {
text: "Some external link node that should have typography applied",
diff --git a/packages/core/package.json b/packages/core/package.json
index ad23f07fe..9a8c863ff 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -53,7 +53,8 @@
"lint": "eslint .",
"package:checks": "yarn publint && yarn attw --pack .",
"postversion": "yarn build",
- "test": "node --experimental-vm-modules $(yarn bin jest) --no-cache --runInBand --bail src/**/tests/**/*.test.ts",
+ "test-cmd": "node --experimental-vm-modules $(yarn bin jest) --no-cache --runInBand --bail",
+ "test": "yarn test-cmd -- src/**/tests/**/*.test.ts",
"types:watch": "tsc-absolute --watch",
"types": "tsc"
},
diff --git a/packages/core/src/requester/Requester.ts b/packages/core/src/requester/Requester.ts
index cedabadc2..8af108ffb 100644
--- a/packages/core/src/requester/Requester.ts
+++ b/packages/core/src/requester/Requester.ts
@@ -5,7 +5,6 @@ import dayjs from "dayjs";
import utc from "dayjs/plugin/utc.js";
import type { Client as WsClient } from "graphql-ws";
import { createClient } from "graphql-ws";
-import NodeWebSocket from "ws";
import { Observable } from "zen-observable-ts";
import type Query from "./Query.js";
@@ -33,7 +32,8 @@ type BodyData = {
};
class Requester {
- private readonly wsClient: WsClient;
+ private wsClient: Promise;
+ private resolveWsClient: ((value: WsClient) => void) | null = null;
constructor(
private readonly nodeKeyCache: NodeKeyCache,
private readonly schemaEndpoint: string,
@@ -44,22 +44,43 @@ class Requester {
private readonly signingKey?: SigningKey,
private readonly fetchImpl: typeof fetch = fetch,
) {
+ this.wsClient = new Promise((resolve) => {
+ this.resolveWsClient = resolve;
+ });
+ void this.initWsClient(baseUrl, authProvider);
+ autoBind(this);
+ }
+
+ private async initWsClient(baseUrl: string, authProvider: AuthProvider) {
+ if (!this.resolveWsClient) {
+ /* If resolveWsClient is null assume already initialized: */
+ return this.wsClient;
+ }
+
let websocketImpl;
- if (typeof WebSocket === "undefined" && typeof window === "undefined") {
- websocketImpl = NodeWebSocket;
+ if (isNode && typeof WebSocket === "undefined") {
+ const wsModule = await import("ws");
+ websocketImpl = wsModule.default;
}
let websocketProtocol = "wss";
if (baseUrl.startsWith("http://")) {
websocketProtocol = "ws";
}
- this.wsClient = createClient({
+
+ const wsClient = createClient({
url: `${websocketProtocol}://${this.stripProtocol(this.baseUrl)}/${
this.schemaEndpoint
}`,
connectionParams: () => authProvider.addWsConnectionParams({}),
webSocketImpl: websocketImpl,
});
- autoBind(this);
+
+ if (this.resolveWsClient) {
+ this.resolveWsClient(wsClient);
+ this.resolveWsClient = null;
+ }
+
+ return wsClient;
}
public async executeQuery(query: Query): Promise {
@@ -106,11 +127,31 @@ class Requester {
return new Observable<{ data: T }>((observer) => {
logger.trace(`Requester.subscribe observer`, observer);
- return this.wsClient.subscribe(bodyData, {
- next: (data) => observer.next(data as { data: T }),
- error: (err) => observer.error(err),
- complete: () => observer.complete(),
- });
+
+ let cleanup: (() => void) | null = null;
+ let canceled = false;
+
+ void (async () => {
+ try {
+ const wsClient = await this.wsClient;
+ if (!canceled) {
+ cleanup = wsClient.subscribe(bodyData, {
+ next: (data) => observer.next(data as { data: T }),
+ error: (err) => observer.error(err),
+ complete: () => observer.complete(),
+ });
+ }
+ } catch (err) {
+ observer.error(err);
+ }
+ })();
+
+ return () => {
+ canceled = true;
+ if (cleanup) {
+ cleanup();
+ }
+ };
});
}
diff --git a/packages/core/src/requester/tests/Requester.test.ts b/packages/core/src/requester/tests/Requester.test.ts
new file mode 100644
index 000000000..cc64bb593
--- /dev/null
+++ b/packages/core/src/requester/tests/Requester.test.ts
@@ -0,0 +1,347 @@
+import { beforeEach, jest } from "@jest/globals";
+
+import type { Client as WsClient } from "graphql-ws";
+import type AuthProvider from "../../auth/AuthProvider.js";
+import type { CryptoInterface } from "../../crypto/crypto.js";
+import type NodeKeyCache from "../../crypto/NodeKeyCache.js";
+import type { SigningKey } from "../../crypto/SigningKey.js";
+import { SigningKeyType } from "../../crypto/types.js";
+import LightsparkException from "../../LightsparkException.js";
+import type Query from "../Query.js";
+
+/* Mocking ESM modules (when running node with --experimental-vm-modules)
+ requires unstable_mockModule, see https://bit.ly/433nRV1 */
+await jest.unstable_mockModule("graphql-ws", () => ({
+ __esModule: true,
+ createClient: jest.fn(),
+}));
+/* Since Requester uses graphql-ws we need a dynamic import after the above mock */
+const { Requester } = await import("../index.js");
+
+describe("Requester", () => {
+ const schemaEndpoint = "graphql";
+ const sdkUserAgent = "test-agent";
+ const baseUrl = "https://api.example.com";
+
+ let nodeKeyCache: NodeKeyCache;
+ let authProvider: AuthProvider;
+ let signingKey: SigningKey;
+ let cryptoImpl: CryptoInterface;
+ let fetchImpl: typeof fetch;
+
+ beforeEach(() => {
+ nodeKeyCache = {
+ getKey: jest.fn(),
+ hasKey: jest.fn(),
+ } as unknown as NodeKeyCache;
+
+ authProvider = {
+ addAuthHeaders: jest.fn(async (headers: Record) => ({
+ ...headers,
+ "X-Test": "1",
+ })),
+ isAuthorized: jest.fn(async () => true),
+ addWsConnectionParams: jest.fn(
+ async (params: Record) => ({
+ ...params,
+ ws: true,
+ }),
+ ),
+ } satisfies AuthProvider;
+
+ signingKey = {
+ type: SigningKeyType.RSASigningKey,
+ sign: jest.fn(async (data: Uint8Array) => new Uint8Array([1, 2, 3])),
+ } satisfies SigningKey;
+
+ cryptoImpl = {
+ decryptSecretWithNodePassword: jest.fn(async () => new ArrayBuffer(0)),
+ generateSigningKeyPair: jest.fn(async () => ({
+ publicKey: "",
+ privateKey: "",
+ })),
+ serializeSigningKey: jest.fn(async () => new ArrayBuffer(0)),
+ getNonce: jest.fn(async () => 123),
+ sign: jest.fn(async () => new ArrayBuffer(0)),
+ importPrivateSigningKey: jest.fn(async () => ""),
+ } satisfies CryptoInterface;
+
+ fetchImpl = jest.fn(
+ async () =>
+ ({
+ ok: true,
+ json: async () => ({ data: { foo: "bar" }, errors: undefined }),
+ statusText: "OK",
+ }) as Response,
+ );
+ });
+
+ it("constructs without error", () => {
+ expect(
+ () =>
+ new Requester(
+ nodeKeyCache,
+ schemaEndpoint,
+ sdkUserAgent,
+ authProvider,
+ baseUrl,
+ cryptoImpl,
+ signingKey,
+ fetchImpl,
+ ),
+ ).not.toThrow();
+ });
+
+ describe("executeQuery", () => {
+ it("calls makeRawRequest and returns constructed object", async () => {
+ const requester = new Requester(
+ nodeKeyCache,
+ schemaEndpoint,
+ sdkUserAgent,
+ authProvider,
+ baseUrl,
+ cryptoImpl,
+ signingKey,
+ fetchImpl,
+ );
+ const query: Query<{ foo: string }> = {
+ queryPayload: "query TestQuery { foo }",
+ variables: { a: 1 },
+ constructObject: (rawData) => ({
+ foo: (rawData as { foo: string }).foo,
+ }),
+ };
+ jest.spyOn(requester, "makeRawRequest").mockResolvedValue({ foo: "bar" });
+ const result = await requester.executeQuery(query);
+ expect(result).toEqual({ foo: "bar" });
+ });
+ });
+
+ describe("makeRawRequest", () => {
+ it("makes a successful request and returns data", async () => {
+ const requester = new Requester(
+ nodeKeyCache,
+ schemaEndpoint,
+ sdkUserAgent,
+ authProvider,
+ baseUrl,
+ cryptoImpl,
+ signingKey,
+ fetchImpl,
+ );
+ const result = await requester.makeRawRequest("query TestQuery { foo }", {
+ a: 1,
+ });
+ expect(result).toEqual({ foo: "bar" });
+ expect(fetchImpl).toHaveBeenCalled();
+ });
+
+ it("throws on invalid query", async () => {
+ const requester = new Requester(
+ nodeKeyCache,
+ schemaEndpoint,
+ sdkUserAgent,
+ authProvider,
+ baseUrl,
+ cryptoImpl,
+ signingKey,
+ fetchImpl,
+ );
+ await expect(requester.makeRawRequest("invalid", {})).rejects.toThrow(
+ LightsparkException,
+ );
+ });
+
+ it("throws on subscription query", async () => {
+ const requester = new Requester(
+ nodeKeyCache,
+ schemaEndpoint,
+ sdkUserAgent,
+ authProvider,
+ baseUrl,
+ cryptoImpl,
+ signingKey,
+ fetchImpl,
+ );
+ await expect(
+ requester.makeRawRequest("subscription TestSub { foo }", {}),
+ ).rejects.toThrow(LightsparkException);
+ });
+
+ it("throws on failed response", async () => {
+ fetchImpl = jest.fn(
+ async () =>
+ ({
+ ok: false,
+ statusText: "Bad Request",
+ json: async () => ({
+ errors: [
+ { message: "fail", extensions: { error_name: "TestError" } },
+ ],
+ }),
+ }) as Response,
+ );
+ const requester = new Requester(
+ nodeKeyCache,
+ schemaEndpoint,
+ sdkUserAgent,
+ authProvider,
+ baseUrl,
+ cryptoImpl,
+ signingKey,
+ fetchImpl,
+ );
+ await expect(
+ requester.makeRawRequest("query TestQuery { foo }", {}),
+ ).rejects.toThrow(LightsparkException);
+ });
+
+ it("throws if response has no data and errors", async () => {
+ fetchImpl = jest.fn(
+ async () =>
+ ({
+ ok: true,
+ json: async () => ({
+ data: undefined,
+ errors: [
+ { message: "fail", extensions: { error_name: "TestError" } },
+ ],
+ }),
+ statusText: "OK",
+ }) as Response,
+ );
+ const requester = new Requester(
+ nodeKeyCache,
+ schemaEndpoint,
+ sdkUserAgent,
+ authProvider,
+ baseUrl,
+ cryptoImpl,
+ signingKey,
+ fetchImpl,
+ );
+ await expect(
+ requester.makeRawRequest("query TestQuery { foo }", {}),
+ ).rejects.toThrow(LightsparkException);
+ });
+ });
+
+ describe("subscribe", () => {
+ it("throws on mutation query", () => {
+ const requester = new Requester(
+ nodeKeyCache,
+ schemaEndpoint,
+ sdkUserAgent,
+ authProvider,
+ baseUrl,
+ cryptoImpl,
+ signingKey,
+ fetchImpl,
+ );
+ expect(() =>
+ requester.subscribe("mutation TestMutation { foo }"),
+ ).toThrow(LightsparkException);
+ });
+
+ it("throws on invalid query", () => {
+ const requester = new Requester(
+ nodeKeyCache,
+ schemaEndpoint,
+ sdkUserAgent,
+ authProvider,
+ baseUrl,
+ cryptoImpl,
+ signingKey,
+ fetchImpl,
+ );
+ expect(() => requester.subscribe("invalid")).toThrow(LightsparkException);
+ });
+
+ it("returns an Observable for a valid subscription", async () => {
+ // Mock wsClient and its subscribe method
+ const wsClient = {
+ subscribe: jest.fn(
+ (
+ _body,
+ handlers: { next?: (data: unknown) => void; complete?: () => void },
+ ) => {
+ setTimeout(() => {
+ handlers.next?.({ data: { foo: "bar" } });
+ handlers.complete?.();
+ }, 10);
+ return jest.fn();
+ },
+ ),
+ } as unknown as WsClient;
+
+ const { createClient } = await import("graphql-ws");
+ (createClient as jest.Mock).mockReturnValue(wsClient);
+
+ const requester = new Requester(
+ nodeKeyCache,
+ schemaEndpoint,
+ sdkUserAgent,
+ authProvider,
+ baseUrl,
+ cryptoImpl,
+ signingKey,
+ fetchImpl,
+ );
+
+ const observable = requester.subscribe<{ foo: string }>(
+ "subscription TestSub { foo }",
+ );
+
+ const results: { foo: string }[] = [];
+ await new Promise((resolve) => {
+ observable.subscribe({
+ next: (data) => {
+ results.push(data.data);
+ },
+ complete: () => {
+ expect(results).toEqual([{ foo: "bar" }]);
+ resolve();
+ },
+ });
+ });
+
+ expect(wsClient.subscribe).toHaveBeenCalled();
+ });
+ });
+
+ describe("signing logic", () => {
+ it("adds signing headers if signingNodeId is provided", async () => {
+ (nodeKeyCache.getKey as jest.Mock).mockReturnValue(signingKey);
+ const requester = new Requester(
+ nodeKeyCache,
+ schemaEndpoint,
+ sdkUserAgent,
+ authProvider,
+ baseUrl,
+ cryptoImpl,
+ undefined,
+ fetchImpl,
+ );
+ const spy = jest.spyOn(signingKey, "sign");
+ await requester.makeRawRequest("query TestQuery { foo }", {}, "node123");
+ expect(spy).toHaveBeenCalled();
+ });
+
+ it("throws if signingKey is missing", async () => {
+ (nodeKeyCache.getKey as jest.Mock).mockReturnValue(undefined);
+ const requester = new Requester(
+ nodeKeyCache,
+ schemaEndpoint,
+ sdkUserAgent,
+ authProvider,
+ baseUrl,
+ cryptoImpl,
+ undefined,
+ fetchImpl,
+ );
+ await expect(
+ requester.makeRawRequest("query TestQuery { foo }", {}, "node123"),
+ ).rejects.toThrow();
+ });
+ });
+});
diff --git a/packages/core/src/utils/errors.ts b/packages/core/src/utils/errors.ts
index c34145410..63ccb454c 100644
--- a/packages/core/src/utils/errors.ts
+++ b/packages/core/src/utils/errors.ts
@@ -1,3 +1,4 @@
+import { isObject } from "./typeGuards.js";
import { type JSONType } from "./types.js";
export const isError = (e: unknown): e is Error => {
@@ -35,15 +36,35 @@ export const isErrorMsg = (e: unknown, msg: string) => {
return false;
};
-export function errorToJSON(err: unknown) {
+/* Make non-enumerable properties like message and stack enumerable so they
+ can be handled by JSON.stringify */
+function normalizeObject(obj: unknown): Record {
+ const normalized: Record = {};
+ if (isObject(obj)) {
+ const props = Object.getOwnPropertyNames(obj);
+ for (const prop of props) {
+ const objRecord = obj as Record;
+ normalized[prop] = objRecord[prop];
+ }
+ }
+ return normalized;
+}
+
+export function errorToJSON(
+ err: unknown,
+ /* Enable stringifying non-primitives globally for subpaths.
+ Useful for enforcing single level error objects: */
+ stringifyObjects = false,
+) {
if (!err) {
return null;
}
- if (
- typeof err === "object" &&
- "toJSON" in err &&
- typeof err.toJSON === "function"
- ) {
+
+ /* Objects can add standard toJSON method to determine JSON.stringify output, https://mzl.la/3Gks9zu: */
+ if (isObject(err) && "toJSON" in err && typeof err.toJSON === "function") {
+ if (stringifyObjects === true) {
+ return objectToJSON(err.toJSON());
+ }
return err.toJSON() as JSONType;
}
@@ -57,7 +78,28 @@ export function errorToJSON(err: unknown) {
return { message: err.message };
}
+ return objectToJSON(err);
+}
+
+function objectToJSON(obj: unknown) {
+ const normalizedObj = normalizeObject(obj);
return JSON.parse(
- JSON.stringify(err, Object.getOwnPropertyNames(err)),
+ JSON.stringify(normalizedObj, (key, value: unknown) => {
+ /* Initial call passes the top level object with empty key: */
+ if (key === "") {
+ return value;
+ }
+
+ const objProps = Object.getOwnPropertyNames(normalizedObj);
+ if (!objProps.includes(key)) {
+ return undefined;
+ }
+
+ if (isObject(value)) {
+ return JSON.stringify(value);
+ }
+
+ return value;
+ }),
) as JSONType;
}
diff --git a/packages/core/src/utils/localStorage.ts b/packages/core/src/utils/localStorage.ts
index ea9866035..110b4309d 100644
--- a/packages/core/src/utils/localStorage.ts
+++ b/packages/core/src/utils/localStorage.ts
@@ -1,15 +1,31 @@
import { type ConfigKeys } from "../constants/index.js";
export function getLocalStorageConfigItem(key: ConfigKeys) {
- return getLocalStorageBoolean(key);
+ const localStorageBoolean = getLocalStorageBoolean(key);
+ // If config not set, just default to false
+ if (localStorageBoolean == null) {
+ return false;
+ }
+
+ return localStorageBoolean;
}
export function getLocalStorageBoolean(key: string) {
/* localStorage is not available in all contexts, use try/catch: */
try {
- return localStorage.getItem(key) === "1";
+ if (localStorage.getItem(key) === "1") {
+ return true;
+ }
+ // Key is not set
+ else if (localStorage.getItem(key) == null) {
+ return null;
+ }
+ // Key is set but not "1"
+ else {
+ return false;
+ }
} catch (e) {
- return false;
+ return null;
}
}
diff --git a/packages/core/src/utils/typeGuards.ts b/packages/core/src/utils/typeGuards.ts
index a70ac4eca..f31f9c6a0 100644
--- a/packages/core/src/utils/typeGuards.ts
+++ b/packages/core/src/utils/typeGuards.ts
@@ -2,7 +2,16 @@ export function isUint8Array(value: unknown): value is Uint8Array {
return value instanceof Uint8Array;
}
-export function isObject(value: unknown): value is Record {
+export function isObject(value: unknown): value is object {
const type = typeof value;
return value != null && (type == "object" || type == "function");
}
+
+export function isRecord(value: unknown): value is Record {
+ return (
+ typeof value === "object" &&
+ value !== null &&
+ !Array.isArray(value) &&
+ Object.prototype.toString.call(value) === "[object Object]"
+ );
+}
diff --git a/packages/core/src/utils/types.ts b/packages/core/src/utils/types.ts
index d16d84175..c9d52cf87 100644
--- a/packages/core/src/utils/types.ts
+++ b/packages/core/src/utils/types.ts
@@ -59,7 +59,3 @@ export type Complete = { [P in keyof T]-?: NonNullable };
export type RequiredKeys = {
[K in keyof T]-?: Record extends Pick ? never : K;
}[keyof T];
-
-export function isRecord(value: unknown): value is Record {
- return typeof value === "object" && value !== null && !Array.isArray(value);
-}
diff --git a/packages/static/images/lightspark-logo.svg b/packages/static/images/lightspark-logo.svg
index da641c776..387d07084 100644
--- a/packages/static/images/lightspark-logo.svg
+++ b/packages/static/images/lightspark-logo.svg
@@ -1,55 +1,14 @@
-
\ No newline at end of file
+
diff --git a/packages/ui/src/components/BirthdayInput.tsx b/packages/ui/src/components/BirthdayInput.tsx
index 6ae76f1e1..d1ee3650d 100644
--- a/packages/ui/src/components/BirthdayInput.tsx
+++ b/packages/ui/src/components/BirthdayInput.tsx
@@ -1,8 +1,6 @@
import { TextInput } from "@lightsparkdev/ui/src/components";
-import { InputSubtext } from "@lightsparkdev/ui/src/styles/fields";
import dayjs from "dayjs";
import customParseFormat from "dayjs/plugin/customParseFormat.js";
-import { useState } from "react";
dayjs.extend(customParseFormat);
@@ -34,6 +32,7 @@ export function formatBirthday(
? dayjs(birthdayStr).startOf("day").format("YYYY-MM-DD")
: undefined;
}
+
/**
* Valides a date string. the required format is MM/DD/YYYY, or the components can be passed in separately.
* @param dayOrDate - The day or date to check
@@ -52,7 +51,51 @@ export function isValidBirthday(
} else {
birthdayStr = dayOrDate;
}
- return dayjs(birthdayStr, "MM/DD/YYYY", true).isValid();
+
+ const date = dayjs(birthdayStr, "MM/DD/YYYY", true);
+ if (!date.isValid()) {
+ return false;
+ }
+
+ const today = dayjs().startOf("day");
+ return date.isBefore(today);
+}
+
+export function formatDateToText(dateStr: string): string {
+ if (!dateStr.trim()) return "";
+
+ const parts = dateStr.split("/");
+ const month = parts[0];
+ const day = parts[1];
+ const year = parts[2];
+
+ if (month && !day && !year) {
+ if (month.length === 2) {
+ const monthNum = parseInt(month);
+ if (monthNum >= 1 && monthNum <= 12) {
+ return dayjs()
+ .month(monthNum - 1)
+ .format("MMMM");
+ }
+ }
+ }
+
+ if (month && day && !year) {
+ const monthNum = parseInt(month);
+ const dayNum = parseInt(day);
+
+ if (monthNum >= 1 && monthNum <= 12) {
+ const testDate = dayjs()
+ .month(monthNum - 1)
+ .date(dayNum);
+ if (testDate.isValid() && testDate.date() === dayNum) {
+ return `${testDate.format("MMMM")} ${day}`;
+ }
+ }
+ }
+
+ const date = dayjs(dateStr, "MM/DD/YYYY", true);
+ return date.isValid() ? date.format("MMMM D, YYYY") : "";
}
export function BirthdayInput({
@@ -60,8 +103,6 @@ export function BirthdayInput({
setDate,
invalidBirthdayError,
}: BirthdayInputProps) {
- const [birthdayValid, setBirthdayValid] = useState(isValidBirthday(date));
-
const birthdayFieldBlurred = Boolean(date.trim());
const handleChange = (newValue: string): void => {
@@ -78,29 +119,28 @@ export function BirthdayInput({
if (value.length > 10) {
value = value.slice(0, 10);
}
- setBirthdayValid(isValidBirthday(value));
-
setDate(value);
};
+ const isCompleteDate = date.length === 10;
+ const isInvalid = isCompleteDate && !isValidBirthday(date);
+
return (
<>
-
>
);
diff --git a/packages/ui/src/components/Button.tsx b/packages/ui/src/components/Button.tsx
index 85865fbe2..e9f3cd35f 100644
--- a/packages/ui/src/components/Button.tsx
+++ b/packages/ui/src/components/Button.tsx
@@ -45,6 +45,7 @@ export const buttonKinds = [
"ghost",
"transparent",
"green33",
+ "green37",
"purple55",
"blue43",
"blue39",
@@ -57,6 +58,8 @@ export const buttonKinds = [
"roundIcon",
"gray",
"grayGradient",
+ "gray99",
+ "white21",
] as const;
export type ButtonKind = (typeof buttonKinds)[number];
@@ -92,6 +95,7 @@ export type ButtonProps = {
onBlur?: ((event: FocusEvent) => void) | undefined;
mt?: number | "auto";
ml?: number | "auto";
+ mb?: number | "auto";
fullWidth?: boolean | undefined;
type?: "button" | "submit";
newTab?: boolean;
@@ -269,6 +273,7 @@ export const Button = forwardRef<
disabled = false,
mt = 0,
ml = 0,
+ mb = 0,
type = "button",
newTab = false,
zIndex = undefined,
@@ -376,6 +381,11 @@ export const Button = forwardRef<
? `${ml}px`
: "auto"
: undefined,
+ marginBottom: mb
+ ? typeof mb === "number"
+ ? `${mb}px`
+ : "auto"
+ : undefined,
},
newTab,
borderRadius,
diff --git a/packages/ui/src/components/CardForm/CardForm.tsx b/packages/ui/src/components/CardForm/CardForm.tsx
index 581fcd7f2..8e1c36404 100644
--- a/packages/ui/src/components/CardForm/CardForm.tsx
+++ b/packages/ui/src/components/CardForm/CardForm.tsx
@@ -2,7 +2,7 @@ import type { Theme } from "@emotion/react";
import { css, useTheme } from "@emotion/react";
import styled from "@emotion/styled";
import type { ComponentProps, FormEvent, ReactNode } from "react";
-import { useCallback } from "react";
+import { Fragment, useCallback } from "react";
import { Link } from "../../router.js";
import { bp } from "../../styles/breakpoints.js";
import { colors } from "../../styles/colors.js";
@@ -47,6 +47,7 @@ import { StyledButtonRowButton } from "../ButtonRow.js";
import { Checkbox, type CheckboxProps } from "../Checkbox.js";
import { ClipboardTextField } from "../ClipboardTextField.js";
import { StyledFileInput } from "../FileInput.js";
+import { Flex } from "../Flex.js";
import { LoadingWrapper } from "../Loading.js";
import { StyledSelect } from "../Select.js";
import { type TextIconAligner } from "../TextIconAligner.js";
@@ -60,8 +61,10 @@ type CardFormProps = {
children?: ReactNode;
disabled?: boolean;
topContent?: ReactNode;
+ aboveHeaderContent?: ReactNode;
title?: string;
titleSize?: TokenSizeKey;
+ titleMarginLeft?: number | undefined;
titleRightIcon?:
| ComponentProps["rightIcon"]
| undefined;
@@ -76,9 +79,16 @@ type CardFormProps = {
shadow?: CardFormShadow;
paddingTop?: CardFormPaddingTop | undefined;
paddingBottom?: CardFormPaddingBottom | undefined;
+ paddingX?: CardFormPaddingX | undefined;
belowFormContent?: ToReactNodesArgs | undefined;
belowFormContentGap?: BelowCardFormContentGap | undefined;
forceMarginAfterSubtitle?: boolean;
+ contentMarginTop?: number | undefined;
+ graphicHeader?: React.ReactNode;
+ centeredContent?: boolean;
+ formButtonTopMargin?: number | undefined;
+ selectMarginTop?: number | undefined;
+ smDontAdjustWidth?: boolean | undefined;
};
type ResolvePropsArgs = {
@@ -87,6 +97,7 @@ type ResolvePropsArgs = {
textAlign?: CardFormTextAlign | undefined;
paddingTop?: CardFormPaddingTop | undefined;
paddingBottom?: CardFormPaddingBottom | undefined;
+ paddingX?: CardFormPaddingX | undefined;
};
function resolveProps(args: ResolvePropsArgs, theme: Theme) {
@@ -96,13 +107,20 @@ function resolveProps(args: ResolvePropsArgs, theme: Theme) {
"paddingTop",
theme,
);
+
const paddingBottom = resolveCardFormProp(
args.paddingBottom,
args.kind,
"paddingBottom",
theme,
);
- const paddingX = resolveCardFormProp(undefined, args.kind, "paddingX", theme);
+
+ const paddingX = resolveCardFormProp(
+ args.paddingX,
+ args.kind,
+ "paddingX",
+ theme,
+ );
const textAlign = resolveCardFormProp(
args.textAlign,
args.kind,
@@ -175,8 +193,10 @@ export function CardForm({
children,
disabled,
topContent = null,
+ aboveHeaderContent,
title,
titleSize = "Large",
+ titleMarginLeft,
description,
full = false,
onSubmit,
@@ -190,10 +210,17 @@ export function CardForm({
textAlign: textAlignProp,
paddingTop: paddingTopProp,
paddingBottom: paddingBottomProp,
+ paddingX: paddingXProp,
belowFormContent,
belowFormContentGap = 0,
forceMarginAfterSubtitle = true,
afterTitleMargin = 40,
+ graphicHeader,
+ centeredContent = false,
+ contentMarginTop,
+ formButtonTopMargin,
+ selectMarginTop,
+ smDontAdjustWidth = false,
}: CardFormProps) {
const theme = useTheme();
const {
@@ -216,6 +243,7 @@ export function CardForm({
shadow: shadowProp,
paddingTop: paddingTopProp,
paddingBottom: paddingBottomProp,
+ paddingX: paddingXProp,
},
theme,
);
@@ -247,28 +275,44 @@ export function CardForm({
const CardFormContentTarget = full ? CardFormContentFull : CardFormContent;
+ const headerData = [
+ aboveHeaderContent ? (
+ {aboveHeaderContent}
+ ) : null,
+ title ? (
+
+
+
+ ) : null,
+ formattedDescription ? (
+
+ {formattedDescription}
+
+ ) : null,
+ ];
+
const content = (
{topContent}
- {title && (
-
-
-
- )}
- {formattedDescription && (
- {formattedDescription}
+ {centeredContent ? (
+ {headerData}
+ ) : (
+ headerData
)}
{children}
@@ -288,31 +332,48 @@ export function CardForm({
smBackgroundColor,
smBorderWidth,
forceMarginAfterSubtitle,
+ smDontAdjustWidth,
};
const Container = full ? CardFormContentFull : CardFormContainer;
return (
-
+
{hasChildForm ? (
{content}
) : (
-
- {content}
-
+
+ {graphicHeader && graphicHeader}
+
+ {content}
+
+
+ )}
+ {belowFormContentNodes && (
+
+ {belowFormContentNodes}
+
)}
-
- {belowFormContentNodes}
-
);
}
+const CenteredHeader = styled.div`
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+ height: 100%;
+ justify-content: center;
+`;
+
const CardFormContainer = styled.div`
display: flex;
flex-direction: column;
@@ -324,11 +385,12 @@ const CardFormContent = styled.div`
align-self: center;
`;
-const CardFormContentFull = styled.div`
+const CardFormContentFull = styled.div<{ paddingBottom?: number | undefined }>`
display: flex;
flex-direction: column;
align-self: center;
height: 100%;
+ padding-bottom: ${({ paddingBottom }) => paddingBottom ?? 0}px;
`;
type BelowCardFormContentProps = {
@@ -406,10 +468,15 @@ const StyledCardFormDataFieldValue = styled.div`
`;
export const CardFormFullWidth = styled.div``;
-export const CardFormFullTopContent = styled.div`
- ${bp.minSm(`
- border-radius: 16px 16px 0 0;
+
+type CardFormFullTopContentProps = {
+ borderRadius?: string;
+};
+export const CardFormFullTopContent = styled.div`
+ ${({ borderRadius = "16px 16px 0 0" }) =>
+ bp.minSm(`
overflow: hidden;
+ border-radius: ${borderRadius};
`)}
`;
@@ -423,6 +490,8 @@ type CardFormInsetProps = {
paddingX: number;
paddingTop: number;
paddingBottom: number;
+ smDontAdjustWidth?: boolean;
+ graphicHeader?: boolean;
};
const formInset = ({
@@ -430,6 +499,8 @@ const formInset = ({
paddingX,
paddingTop,
paddingBottom,
+ smDontAdjustWidth,
+ graphicHeader,
}: CardFormInsetProps) => css`
margin-left: auto;
margin-right: auto;
@@ -444,18 +515,24 @@ const formInset = ({
`)}
${bp.sm(`
- padding: 0;
+ padding: ${graphicHeader ? "24px" : "0"};
`)}
- ${standardContentInset.smCSS}
+ ${graphicHeader || smDontAdjustWidth
+ ? `width: 100%;`
+ : standardContentInset.smCSS}
& ${CardFormFullWidth}, & ${CardFormFullTopContent} {
- ${bp.sm(`
+ ${smDontAdjustWidth
+ ? ""
+ : bp.sm(`
width: calc(100% + ${standardContentInset.smPx * 2}px);
margin-left: -${standardContentInset.smPx}px;
`)}
- ${bp.minSm(`
+ ${smDontAdjustWidth
+ ? ""
+ : bp.minSm(`
width: calc(100% + ${paddingX * 2}px);
margin-left: -${paddingX}px;
`)}
@@ -482,6 +559,10 @@ type StyledCardFormStyleProps = {
smBackgroundColor: CardFormBackgroundColor;
smBorderWidth: CardFormBorderWidth;
forceMarginAfterSubtitle: boolean | undefined;
+ graphicHeader?: boolean | undefined;
+ formButtonTopMargin?: number | undefined;
+ selectMarginTop?: number | undefined;
+ smDontAdjustWidth?: boolean | undefined;
};
const StyledCardFormStyle = ({
@@ -499,9 +580,29 @@ const StyledCardFormStyle = ({
smBackgroundColor,
smBorderWidth,
forceMarginAfterSubtitle = true,
+ graphicHeader,
+ formButtonTopMargin = 32,
+ selectMarginTop = inputSpacingPx,
+ smDontAdjustWidth = false,
}: StyledCardFormStyleProps & { theme: Theme }) => {
return css`
- ${formInset({ wide, paddingX, paddingTop, paddingBottom })}
+ ${graphicHeader
+ ? formInset({
+ wide,
+ paddingX,
+ paddingTop,
+ paddingBottom,
+ graphicHeader,
+ smDontAdjustWidth,
+ })
+ : formInset({
+ wide,
+ paddingX,
+ paddingTop,
+ paddingBottom,
+ smDontAdjustWidth,
+ })}
+
${shadow === "soft"
? standardCardShadow
: shadow === "hard"
@@ -522,7 +623,11 @@ const StyledCardFormStyle = ({
`)}
${bp.minSm(`
- border-radius: ${borderRadius}px;
+ border-radius: ${
+ graphicHeader
+ ? `0 0 ${borderRadius}px ${borderRadius}px`
+ : `${borderRadius}px`
+ };
`)}
${CardHeadline}, ${CardFormSubtitle} {
@@ -542,7 +647,7 @@ const StyledCardFormStyle = ({
}
${StyledSelect}, ${ToggleContainer} {
- margin-top: ${inputSpacingPx}px;
+ margin-top: ${selectMarginTop}px;
}
${CardFormFieldLabel} {
@@ -578,7 +683,9 @@ const StyledCardFormStyle = ({
}
& > ${ButtonSelector()}, & > ${select(CardFormNearButtonColumn)} {
- ${formButtonTopMarginStyle}
+ ${formButtonTopMargin !== undefined
+ ? `margin-top: ${formButtonTopMargin}px;`
+ : formButtonTopMarginStyle}
}
}
`;
@@ -593,10 +700,15 @@ const StyledCardFormDiv =
const CardHeadline = styled.div<{
hasTopContent: boolean;
afterTitleMargin: number;
+ contentMarginTop?: number | undefined;
+ titleMarginLeft?: number | undefined;
}>`
padding: 0 ${Spacing.px.xs};
- ${({ hasTopContent }) => (hasTopContent ? "margin-top: 24px;" : "")}
+ ${({ hasTopContent, contentMarginTop }) =>
+ hasTopContent || contentMarginTop !== undefined
+ ? `margin-top: ${contentMarginTop ?? 32}px;`
+ : ""}
& + *:not(${CardFormSubtitle.toString()}) {
margin-top: ${({ afterTitleMargin }) => afterTitleMargin}px;
@@ -605,6 +717,9 @@ const CardHeadline = styled.div<{
& + ${CardFormSubtitle} {
margin-top: 12px;
}
+
+ ${({ titleMarginLeft }) =>
+ titleMarginLeft !== undefined && `margin-left: ${titleMarginLeft}px;`}
`;
type CardFormTextWithLinkProps = {
@@ -698,8 +813,8 @@ function resolveCardFormProp(
return (
/** props may be unset for a given kind but theme defaults always exist,
* so this will always resolve a value: */
- prop ||
- theme.cardForm.kinds[kind]?.[defaultKey] ||
- theme.cardForm[defaultKey]
+ prop !== undefined
+ ? prop
+ : theme.cardForm.kinds[kind]?.[defaultKey] || theme.cardForm[defaultKey]
);
}
diff --git a/packages/ui/src/components/CardForm/CardFormHeadline.tsx b/packages/ui/src/components/CardForm/CardFormHeadline.tsx
new file mode 100644
index 000000000..74c303d5c
--- /dev/null
+++ b/packages/ui/src/components/CardForm/CardFormHeadline.tsx
@@ -0,0 +1,27 @@
+import styled from "@emotion/styled";
+import { type ReactNode } from "react";
+
+export type CardFormHeaderProps = {
+ children?: ReactNode;
+ bundle?: boolean;
+ grow?: boolean;
+};
+
+export function CardFormHeader({
+ children,
+ bundle = false,
+}: CardFormHeaderProps) {
+ if (!bundle) {
+ return children;
+ }
+
+ return {children};
+}
+
+const Wrapper = styled.div<{ grow?: boolean }>`
+ display: flex;
+ flex-direction: column;
+ ${({ grow }) => grow && `flex-grow: 1;`}
+ height: 100%;
+ justify-content: center;
+`;
diff --git a/packages/ui/src/components/Checkbox.tsx b/packages/ui/src/components/Checkbox.tsx
index 3d09264a1..d3b651bd5 100644
--- a/packages/ui/src/components/Checkbox.tsx
+++ b/packages/ui/src/components/Checkbox.tsx
@@ -13,6 +13,9 @@ export type CheckboxProps = {
id?: string;
label?: ToReactNodesArgs | undefined;
mt?: number;
+ mb?: number;
+ ml?: number;
+ mr?: number;
alignItems?: "center" | "flex-start";
disabled?: boolean;
typography?: PartialSimpleTypographyProps;
@@ -24,6 +27,9 @@ export function Checkbox({
id,
label,
mt = 0,
+ mb = 0,
+ ml = 0,
+ mr = 0,
alignItems = "center",
disabled = false,
typography: typographyProp,
@@ -41,7 +47,7 @@ export function Checkbox({
const content = toReactNodes(nodesWithTypography);
return (
-
+
`
export const CheckboxContainer = styled.span<{
mt: number;
+ mb: number;
+ ml: number;
+ mr: number;
alignItems: string;
}>`
display: flex;
${({ mt }) => (mt === 0 ? "" : `margin-top: ${mt}px;`)}
+ ${({ mb }) => (mb === 0 ? "" : `margin-bottom: ${mb}px;`)}
+ ${({ ml }) => (ml === 0 ? "" : `margin-left: ${ml}px;`)}
+ ${({ mr }) => (mr === 0 ? "" : `margin-right: ${mr}px;`)}
align-items: ${({ alignItems }) => alignItems};
& + & {
diff --git a/packages/ui/src/components/DataManagerTable/EnumFilter.tsx b/packages/ui/src/components/DataManagerTable/EnumFilter.tsx
index 65f75f6cf..31e149ab6 100644
--- a/packages/ui/src/components/DataManagerTable/EnumFilter.tsx
+++ b/packages/ui/src/components/DataManagerTable/EnumFilter.tsx
@@ -70,7 +70,7 @@ export const EnumFilter = ({