diff --git a/.mise.toml b/.mise.toml index 32c1a1ae0..c2ed2775e 100644 --- a/.mise.toml +++ b/.mise.toml @@ -2,7 +2,7 @@ MISE_NODE_COREPACK = true [tools] -node = "20" +node = "20.19" [tasks.deps] description = "Install all JS dependencies" diff --git a/CLAUDE.md b/CLAUDE.md index 190b21a94..9cb1ab5d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,6 +45,10 @@ yarn workspace @lightsparkdev/ui test - Use workspace protocol for internal deps: `"@lightsparkdev/ui": "*"` - Shared configs: `@lightsparkdev/{tsconfig,eslint-config}` +### Enums +Prefer generated TypeScript enums from `src/generated/graphql` rather than raw strings when +available. This ensures type safety and keeps code in sync with the schema. + ### GraphQL After Python schema changes: ```bash diff --git a/package.json b/package.json index a854f268b..3d02ba099 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,17 @@ "ts-prune": "^0.10.3", "turbo": "^2.5.4" }, + "dependenciesMeta": { + "@central-icons-react/round-filled-radius-3-stroke-1.5": { + "built": false + }, + "@central-icons-react/round-outlined-radius-0-stroke-1.5": { + "built": false + }, + "@central-icons-react/round-outlined-radius-3-stroke-1.5": { + "built": false + } + }, "engines": { "node": ">=18" }, diff --git a/packages/core/src/crypto/crypto.ts b/packages/core/src/crypto/crypto.ts index 0f226fa80..1bce3e26f 100644 --- a/packages/core/src/crypto/crypto.ts +++ b/packages/core/src/crypto/crypto.ts @@ -24,7 +24,7 @@ export type CryptoInterface = { format: "pkcs8" | "spki", ) => Promise; - getNonce: () => Promise; + getNonce: () => Promise; sign: ( keyOrAlias: CryptoKey | string, @@ -229,10 +229,7 @@ const serializeSigningKey = async ( const getNonce = async () => { const nonceSt = await getRandomValues32(new Uint32Array(2)); const [upper, lower] = nonceSt; - const nonce = (BigInt(upper) << 32n) | BigInt(lower); - // Note: We lose some precision here going from bigint to number - // because js numbers are floats, but it's ok. - return Number(nonce); + return (BigInt(upper) << 32n) | BigInt(lower); }; const sign = async ( diff --git a/packages/core/src/crypto/tests/crypto.test.ts b/packages/core/src/crypto/tests/crypto.test.ts index cb44bc9e5..8aaf7a0df 100644 --- a/packages/core/src/crypto/tests/crypto.test.ts +++ b/packages/core/src/crypto/tests/crypto.test.ts @@ -21,6 +21,24 @@ describe("Crypto tests", () => { test("should generate a valid nonce", async () => { const nonce = await DefaultCrypto.getNonce(); - expect(nonce).toBeGreaterThan(0); + expect(nonce > 0n).toBe(true); + }, 10_000); + + test("should generate nonces that exceed Number.MAX_SAFE_INTEGER without precision loss", async () => { + const nonces = await Promise.all( + Array.from({ length: 100 }, () => DefaultCrypto.getNonce()), + ); + const maxSafeInteger = BigInt(Number.MAX_SAFE_INTEGER); + + for (const nonce of nonces) { + expect(typeof nonce).toBe("bigint"); + // A 64-bit nonce converted to Number and back will lose precision if it + // exceeds MAX_SAFE_INTEGER. Verify the round-trip is lossless: + expect(BigInt(nonce.toString())).toBe(nonce); + } + // With 64 bits, at least some nonces should exceed MAX_SAFE_INTEGER. The + // probability of all 100 fitting in 53 bits is negligible (~2^-1100). + const hasLargeNonce = nonces.some((n: bigint) => n > maxSafeInteger); + expect(hasLargeNonce).toBe(true); }, 10_000); }); diff --git a/packages/core/src/requester/Requester.ts b/packages/core/src/requester/Requester.ts index dc205f5c7..a58573404 100644 --- a/packages/core/src/requester/Requester.ts +++ b/packages/core/src/requester/Requester.ts @@ -284,7 +284,7 @@ class Requester { query, variables, operationName, - nonce, + nonce: nonce.toString(), expires_at: expiration, }; diff --git a/packages/core/src/requester/tests/DefaultRequester.test.ts b/packages/core/src/requester/tests/DefaultRequester.test.ts index fbba59693..97283f947 100644 --- a/packages/core/src/requester/tests/DefaultRequester.test.ts +++ b/packages/core/src/requester/tests/DefaultRequester.test.ts @@ -67,7 +67,7 @@ describe("DefaultRequester", () => { }), ), serializeSigningKey: jest.fn(() => Promise.resolve(new ArrayBuffer(0))), - getNonce: jest.fn(() => Promise.resolve(123)), + getNonce: jest.fn(() => Promise.resolve(123n)), sign: jest.fn(() => Promise.resolve(new ArrayBuffer(0))), importPrivateSigningKey: jest.fn(() => Promise.resolve("")), } satisfies CryptoInterface; diff --git a/packages/core/src/requester/tests/Requester.test.ts b/packages/core/src/requester/tests/Requester.test.ts index f602a0810..6c9651795 100644 --- a/packages/core/src/requester/tests/Requester.test.ts +++ b/packages/core/src/requester/tests/Requester.test.ts @@ -68,7 +68,7 @@ describe("Requester", () => { }), ), serializeSigningKey: jest.fn(() => Promise.resolve(new ArrayBuffer(0))), - getNonce: jest.fn(() => Promise.resolve(123)), + getNonce: jest.fn(() => Promise.resolve(123n)), sign: jest.fn(() => Promise.resolve(new ArrayBuffer(0))), importPrivateSigningKey: jest.fn(() => Promise.resolve("")), } satisfies CryptoInterface; diff --git a/packages/core/src/utils/currency.ts b/packages/core/src/utils/currency.ts index 3c8bf6922..8c9657f16 100644 --- a/packages/core/src/utils/currency.ts +++ b/packages/core/src/utils/currency.ts @@ -45,7 +45,14 @@ export const CurrencyUnit = { RWF: "RWF", ZMW: "ZMW", AED: "AED", + BDT: "BDT", + COP: "COP", + EGP: "EGP", + GHS: "GHS", GTQ: "GTQ", + HTG: "HTG", + JMD: "JMD", + PKR: "PKR", USDT: "USDT", USDC: "USDC", @@ -62,6 +69,14 @@ export const CurrencyUnit = { Inr: "INR", Brl: "BRL", Aed: "AED", + Bdt: "BDT", + Cop: "COP", + Egp: "EGP", + Ghs: "GHS", + Gtq: "GTQ", + Htg: "HTG", + Jmd: "JMD", + Pkr: "PKR", Usdt: "USDT", Usdc: "USDC", } as const; @@ -116,7 +131,14 @@ const standardUnitConversionObj = { [CurrencyUnit.RWF]: (v: number) => v, [CurrencyUnit.ZMW]: (v: number) => v, [CurrencyUnit.AED]: (v: number) => v, + [CurrencyUnit.BDT]: (v: number) => v, + [CurrencyUnit.COP]: (v: number) => v, + [CurrencyUnit.EGP]: (v: number) => v, + [CurrencyUnit.GHS]: (v: number) => v, [CurrencyUnit.GTQ]: (v: number) => v, + [CurrencyUnit.HTG]: (v: number) => v, + [CurrencyUnit.JMD]: (v: number) => v, + [CurrencyUnit.PKR]: (v: number) => v, [CurrencyUnit.USDT]: (v: number) => v, [CurrencyUnit.USDC]: (v: number) => v, }; @@ -170,7 +192,14 @@ const CONVERSION_MAP = { [CurrencyUnit.RWF]: toBitcoinConversion, [CurrencyUnit.ZMW]: toBitcoinConversion, [CurrencyUnit.AED]: toBitcoinConversion, + [CurrencyUnit.BDT]: toBitcoinConversion, + [CurrencyUnit.COP]: toBitcoinConversion, + [CurrencyUnit.EGP]: toBitcoinConversion, + [CurrencyUnit.GHS]: toBitcoinConversion, [CurrencyUnit.GTQ]: toBitcoinConversion, + [CurrencyUnit.HTG]: toBitcoinConversion, + [CurrencyUnit.JMD]: toBitcoinConversion, + [CurrencyUnit.PKR]: toBitcoinConversion, [CurrencyUnit.USDT]: toBitcoinConversion, [CurrencyUnit.USDC]: toBitcoinConversion, }, @@ -208,7 +237,14 @@ const CONVERSION_MAP = { [CurrencyUnit.RWF]: toMicrobitcoinConversion, [CurrencyUnit.ZMW]: toMicrobitcoinConversion, [CurrencyUnit.AED]: toMicrobitcoinConversion, + [CurrencyUnit.BDT]: toMicrobitcoinConversion, + [CurrencyUnit.COP]: toMicrobitcoinConversion, + [CurrencyUnit.EGP]: toMicrobitcoinConversion, + [CurrencyUnit.GHS]: toMicrobitcoinConversion, [CurrencyUnit.GTQ]: toMicrobitcoinConversion, + [CurrencyUnit.HTG]: toMicrobitcoinConversion, + [CurrencyUnit.JMD]: toMicrobitcoinConversion, + [CurrencyUnit.PKR]: toMicrobitcoinConversion, [CurrencyUnit.USDT]: toMicrobitcoinConversion, [CurrencyUnit.USDC]: toMicrobitcoinConversion, }, @@ -246,7 +282,14 @@ const CONVERSION_MAP = { [CurrencyUnit.RWF]: toMillibitcoinConversion, [CurrencyUnit.ZMW]: toMillibitcoinConversion, [CurrencyUnit.AED]: toMillibitcoinConversion, + [CurrencyUnit.BDT]: toMillibitcoinConversion, + [CurrencyUnit.COP]: toMillibitcoinConversion, + [CurrencyUnit.EGP]: toMillibitcoinConversion, + [CurrencyUnit.GHS]: toMillibitcoinConversion, [CurrencyUnit.GTQ]: toMillibitcoinConversion, + [CurrencyUnit.HTG]: toMillibitcoinConversion, + [CurrencyUnit.JMD]: toMillibitcoinConversion, + [CurrencyUnit.PKR]: toMillibitcoinConversion, [CurrencyUnit.USDT]: toMillibitcoinConversion, [CurrencyUnit.USDC]: toMillibitcoinConversion, }, @@ -284,7 +327,14 @@ const CONVERSION_MAP = { [CurrencyUnit.RWF]: toMillisatoshiConversion, [CurrencyUnit.ZMW]: toMillisatoshiConversion, [CurrencyUnit.AED]: toMillisatoshiConversion, + [CurrencyUnit.BDT]: toMillisatoshiConversion, + [CurrencyUnit.COP]: toMillisatoshiConversion, + [CurrencyUnit.EGP]: toMillisatoshiConversion, + [CurrencyUnit.GHS]: toMillisatoshiConversion, [CurrencyUnit.GTQ]: toMillisatoshiConversion, + [CurrencyUnit.HTG]: toMillisatoshiConversion, + [CurrencyUnit.JMD]: toMillisatoshiConversion, + [CurrencyUnit.PKR]: toMillisatoshiConversion, [CurrencyUnit.USDT]: toMillisatoshiConversion, [CurrencyUnit.USDC]: toMillisatoshiConversion, }, @@ -322,7 +372,14 @@ const CONVERSION_MAP = { [CurrencyUnit.RWF]: toNanobitcoinConversion, [CurrencyUnit.ZMW]: toNanobitcoinConversion, [CurrencyUnit.AED]: toNanobitcoinConversion, + [CurrencyUnit.BDT]: toNanobitcoinConversion, + [CurrencyUnit.COP]: toNanobitcoinConversion, + [CurrencyUnit.EGP]: toNanobitcoinConversion, + [CurrencyUnit.GHS]: toNanobitcoinConversion, [CurrencyUnit.GTQ]: toNanobitcoinConversion, + [CurrencyUnit.HTG]: toNanobitcoinConversion, + [CurrencyUnit.JMD]: toNanobitcoinConversion, + [CurrencyUnit.PKR]: toNanobitcoinConversion, [CurrencyUnit.USDT]: toNanobitcoinConversion, [CurrencyUnit.USDC]: toNanobitcoinConversion, }, @@ -360,7 +417,14 @@ const CONVERSION_MAP = { [CurrencyUnit.RWF]: toSatoshiConversion, [CurrencyUnit.ZMW]: toSatoshiConversion, [CurrencyUnit.AED]: toSatoshiConversion, + [CurrencyUnit.BDT]: toSatoshiConversion, + [CurrencyUnit.COP]: toSatoshiConversion, + [CurrencyUnit.EGP]: toSatoshiConversion, + [CurrencyUnit.GHS]: toSatoshiConversion, [CurrencyUnit.GTQ]: toSatoshiConversion, + [CurrencyUnit.HTG]: toSatoshiConversion, + [CurrencyUnit.JMD]: toSatoshiConversion, + [CurrencyUnit.PKR]: toSatoshiConversion, [CurrencyUnit.USDT]: toSatoshiConversion, [CurrencyUnit.USDC]: toSatoshiConversion, }, @@ -391,7 +455,14 @@ const CONVERSION_MAP = { [CurrencyUnit.RWF]: standardUnitConversionObj, [CurrencyUnit.ZMW]: standardUnitConversionObj, [CurrencyUnit.AED]: standardUnitConversionObj, + [CurrencyUnit.BDT]: standardUnitConversionObj, + [CurrencyUnit.COP]: standardUnitConversionObj, + [CurrencyUnit.EGP]: standardUnitConversionObj, + [CurrencyUnit.GHS]: standardUnitConversionObj, [CurrencyUnit.GTQ]: standardUnitConversionObj, + [CurrencyUnit.HTG]: standardUnitConversionObj, + [CurrencyUnit.JMD]: standardUnitConversionObj, + [CurrencyUnit.PKR]: standardUnitConversionObj, [CurrencyUnit.USDT]: standardUnitConversionObj, [CurrencyUnit.USDC]: standardUnitConversionObj, }; @@ -482,7 +553,14 @@ export type CurrencyMap = { [CurrencyUnit.RWF]: number; [CurrencyUnit.ZMW]: number; [CurrencyUnit.AED]: number; + [CurrencyUnit.BDT]: number; + [CurrencyUnit.COP]: number; + [CurrencyUnit.EGP]: number; + [CurrencyUnit.GHS]: number; [CurrencyUnit.GTQ]: number; + [CurrencyUnit.HTG]: number; + [CurrencyUnit.JMD]: number; + [CurrencyUnit.PKR]: number; [CurrencyUnit.USDT]: number; [CurrencyUnit.USDC]: number; [CurrencyUnit.FUTURE_VALUE]: number; @@ -523,7 +601,14 @@ export type CurrencyMap = { [CurrencyUnit.RWF]: string; [CurrencyUnit.ZMW]: string; [CurrencyUnit.AED]: string; + [CurrencyUnit.BDT]: string; + [CurrencyUnit.COP]: string; + [CurrencyUnit.EGP]: string; + [CurrencyUnit.GHS]: string; [CurrencyUnit.GTQ]: string; + [CurrencyUnit.HTG]: string; + [CurrencyUnit.JMD]: string; + [CurrencyUnit.PKR]: string; [CurrencyUnit.USDT]: string; [CurrencyUnit.USDC]: string; [CurrencyUnit.FUTURE_VALUE]: string; @@ -745,7 +830,14 @@ function convertCurrencyAmountValues( rwf: CurrencyUnit.RWF, zmw: CurrencyUnit.ZMW, aed: CurrencyUnit.AED, + bdt: CurrencyUnit.BDT, + cop: CurrencyUnit.COP, + egp: CurrencyUnit.EGP, + ghs: CurrencyUnit.GHS, gtq: CurrencyUnit.GTQ, + htg: CurrencyUnit.HTG, + jmd: CurrencyUnit.JMD, + pkr: CurrencyUnit.PKR, mibtc: CurrencyUnit.MICROBITCOIN, mlbtc: CurrencyUnit.MILLIBITCOIN, nbtc: CurrencyUnit.NANOBITCOIN, @@ -831,7 +923,14 @@ export function mapCurrencyAmount( rwf, zmw, aed, + bdt, + cop, + egp, + ghs, gtq, + htg, + jmd, + pkr, usdt, usdc, } = convertCurrencyAmountValues(unit, value, unitsPerBtc, conversionOverride); @@ -867,7 +966,14 @@ export function mapCurrencyAmount( [CurrencyUnit.RWF]: rwf, [CurrencyUnit.ZMW]: zmw, [CurrencyUnit.AED]: aed, + [CurrencyUnit.BDT]: bdt, + [CurrencyUnit.COP]: cop, + [CurrencyUnit.EGP]: egp, + [CurrencyUnit.GHS]: ghs, [CurrencyUnit.GTQ]: gtq, + [CurrencyUnit.HTG]: htg, + [CurrencyUnit.JMD]: jmd, + [CurrencyUnit.PKR]: pkr, [CurrencyUnit.MICROBITCOIN]: mibtc, [CurrencyUnit.MILLIBITCOIN]: mlbtc, [CurrencyUnit.NANOBITCOIN]: nbtc, @@ -1007,10 +1113,38 @@ export function mapCurrencyAmount( value: aed, unit: CurrencyUnit.AED, }), + [CurrencyUnit.BDT]: formatCurrencyStr({ + value: bdt, + unit: CurrencyUnit.BDT, + }), + [CurrencyUnit.COP]: formatCurrencyStr({ + value: cop, + unit: CurrencyUnit.COP, + }), + [CurrencyUnit.EGP]: formatCurrencyStr({ + value: egp, + unit: CurrencyUnit.EGP, + }), + [CurrencyUnit.GHS]: formatCurrencyStr({ + value: ghs, + unit: CurrencyUnit.GHS, + }), [CurrencyUnit.GTQ]: formatCurrencyStr({ value: gtq, unit: CurrencyUnit.GTQ, }), + [CurrencyUnit.HTG]: formatCurrencyStr({ + value: htg, + unit: CurrencyUnit.HTG, + }), + [CurrencyUnit.JMD]: formatCurrencyStr({ + value: jmd, + unit: CurrencyUnit.JMD, + }), + [CurrencyUnit.PKR]: formatCurrencyStr({ + value: pkr, + unit: CurrencyUnit.PKR, + }), [CurrencyUnit.USDT]: formatCurrencyStr({ value: usdt, unit: CurrencyUnit.USDT, @@ -1145,8 +1279,22 @@ export const abbrCurrencyUnit = (unit: CurrencyUnitType) => { return "ZMW"; case CurrencyUnit.AED: return "AED"; + case CurrencyUnit.BDT: + return "BDT"; + case CurrencyUnit.COP: + return "COP"; + case CurrencyUnit.EGP: + return "EGP"; + case CurrencyUnit.GHS: + return "GHS"; case CurrencyUnit.GTQ: return "GTQ"; + case CurrencyUnit.HTG: + return "HTG"; + case CurrencyUnit.JMD: + return "JMD"; + case CurrencyUnit.PKR: + return "PKR"; } return "Unsupported CurrencyUnit"; }; @@ -1211,16 +1359,21 @@ export function formatCurrencyStr( /* Yellowcard 2-decimal African currencies (stored in smallest units): */ CurrencyUnit.MWK, CurrencyUnit.ZMW, - /* Tazapay currencies: Tazapay standardizes all fiat to 2 decimal places, - * so CurrencyAmount values from Tazapay are stored in smallest units (1/100 of base unit) - * even for currencies with no real sub-units (e.g. IDR, VND): */ + /* Tazapay currencies with 2 decimal places (stored in smallest units): */ CurrencyUnit.IDR, - CurrencyUnit.VND, CurrencyUnit.THB, CurrencyUnit.MYR, CurrencyUnit.CAD, CurrencyUnit.DKK, CurrencyUnit.AED, + CurrencyUnit.BDT, + CurrencyUnit.COP, + CurrencyUnit.EGP, + CurrencyUnit.GHS, + CurrencyUnit.GTQ, + CurrencyUnit.HTG, + CurrencyUnit.JMD, + CurrencyUnit.PKR, CurrencyUnit.HKD, CurrencyUnit.SGD, ] as string[]; diff --git a/packages/origin/.gitignore b/packages/origin/.gitignore new file mode 100644 index 000000000..9a587fdbd --- /dev/null +++ b/packages/origin/.gitignore @@ -0,0 +1,25 @@ +# Override monorepo's blanket lib/ ignore — origin's src/lib/ is source code +!src/lib/ + +# Next.js (dev server) +.next/ +out/ +next-env.d.ts + +# Build output +dist/ +storybook-static/ + +# Playwright +playwright-report/ +test-results/ +playwright/.cache/ +.playwright-mcp/ + +# TypeScript +*.tsbuildinfo + +# Environment +.env +.env.local +.env.*.local diff --git a/packages/origin/.npmignore b/packages/origin/.npmignore new file mode 100644 index 000000000..dd7f4fc86 --- /dev/null +++ b/packages/origin/.npmignore @@ -0,0 +1,4 @@ +src/**/*.test.tsx +src/**/*.test.ts +src/**/*.stories.tsx +src/**/*.test-stories.tsx diff --git a/packages/origin/.storybook/main.ts b/packages/origin/.storybook/main.ts new file mode 100644 index 000000000..180be8958 --- /dev/null +++ b/packages/origin/.storybook/main.ts @@ -0,0 +1,12 @@ +import type { StorybookConfig } from '@storybook/nextjs'; + +const config: StorybookConfig = { + stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + framework: { + name: '@storybook/nextjs', + options: {}, + }, + staticDirs: ['../public'], +}; + +export default config; diff --git a/packages/origin/.storybook/preview.ts b/packages/origin/.storybook/preview.ts new file mode 100644 index 000000000..d749a296f --- /dev/null +++ b/packages/origin/.storybook/preview.ts @@ -0,0 +1,22 @@ +import type { Preview } from '@storybook/react'; +import '../src/app/globals.scss'; + +const preview: Preview = { + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + backgrounds: { + default: 'light', + values: [ + { name: 'light', value: '#f8f8f7' }, + { name: 'dark', value: '#1a1a1a' }, + ], + }, + }, +}; + +export default preview; diff --git a/packages/origin/.stylelintrc.json b/packages/origin/.stylelintrc.json new file mode 100644 index 000000000..6c0fa0908 --- /dev/null +++ b/packages/origin/.stylelintrc.json @@ -0,0 +1,36 @@ +{ + "extends": ["stylelint-config-standard-scss"], + "rules": { + "declaration-no-important": true, + + "selector-class-pattern": null, + "scss/at-mixin-pattern": null, + "scss/dollar-variable-pattern": null, + "scss/percent-placeholder-pattern": null, + "custom-property-pattern": null, + "keyframes-name-pattern": null, + "scss/at-function-pattern": null, + + "declaration-empty-line-before": null, + "at-rule-empty-line-before": null, + "rule-empty-line-before": null, + "scss/double-slash-comment-empty-line-before": null, + "declaration-block-no-redundant-longhand-properties": null, + + "color-function-notation": null, + "color-function-alias-notation": null, + "alpha-value-notation": null, + "color-hex-length": null, + "number-max-precision": null, + "value-keyword-case": null, + "length-zero-no-unit": null, + + "selector-not-notation": null, + "property-no-vendor-prefix": null, + "property-no-unknown": null, + "property-no-deprecated": null, + "no-invalid-position-declaration": null, + "declaration-block-single-line-max-declarations": null + }, + "ignoreFiles": ["src/tokens/_variables.scss", "src/tokens/_effects.scss", "src/tokens/_text-styles.scss"] +} diff --git a/packages/origin/CHANGELOG.md b/packages/origin/CHANGELOG.md new file mode 100644 index 000000000..0ce4e74bf --- /dev/null +++ b/packages/origin/CHANGELOG.md @@ -0,0 +1,26 @@ +# Changelog + +## 0.13.6 → 0.14.0 (2026-03-05) + +- Added new chart components to the design system +- Added new Drawer component +- Introduced new design tokens +- Added Skeleton component for loading states + + +## 0.13.5 → 0.13.6 (2026-02-27) + +- Chart grid lines are now more visible (opacity 0.06 → 0.18) +- All chart axis padding is now measurement-based — labels adapt to formatted content instead of using a fixed 48px +- Y-axis tick count scales dynamically with chart height +- X-axis label thinning applied consistently across all chart types +- Horizontal BarChart value axis uses canvas-measured label widths for spacing +- ComposedChart dual-axis right padding is now dynamic +- Uptime: new `label` prop for an always-visible resting label (replaces `tooltip`) +- Uptime: hover indicator changed from opacity dimming to subtle height increase +- **Breaking:** `Chart.Uptime` `tooltip` prop removed, replaced by `label` and `labelStatus` + + +## 0.13.4 → 0.13.5 (2026-02-27) + +- Internal maintenance release (no user-facing changes) diff --git a/packages/origin/CONTEXT.md b/packages/origin/CONTEXT.md new file mode 100644 index 000000000..a56a8a4c8 --- /dev/null +++ b/packages/origin/CONTEXT.md @@ -0,0 +1,308 @@ +# Origin - Project Context + +> **Purpose**: This document provides full context for AI assistants to continue work on this project. + +--- + +## Vision & Approach + +Origin is a **complete rewrite** of the Origin Design System, shifting from a complex spec-generation pipeline to a **simpler, designer-first workflow**. + +### Core Philosophy + +1. **Base UI for Behavior** — Use [Base UI](https://base-ui.com) unstyled components for accessibility and interaction logic +2. **Figma for Visuals** — Extract CSS directly from Figma Dev Mode (already tokenized) +3. **Minimal Transformation** — Reduce pipeline complexity to minimize drift between design and code +4. **Designer-First** — The workflow is optimized for a designer who codes, not an engineer who designs + +### The Old Problem (v1) + +- Complex MCP spec-generation pipeline with many transformation steps +- Design drift accumulated at each transformation layer +- Heavy engineering overhead for maintaining generators +- Memory/context issues across sessions + +### The New Solution (v2) + +``` +Figma Design → Figma Lint Plugin → Base UI Component + Figma CSS → Done +``` + +--- + +## What's Been Built + +### 1. Base UI Lint Plugin (`tools/base-ui-lint/`) + +A Figma plugin that validates component structures against Base UI's expected anatomy. + +**Features:** +- ✅ 37 component rules (100% Base UI coverage) +- ✅ Detects missing required parts +- ✅ Suggests renames for aliased names (e.g., "Content" → "Panel") +- ✅ Auto-fix applies renames across all variants +- ✅ Only matches structural nodes (frames/groups), not text nodes + +**Usage:** +1. Select a component in Figma +2. Run the plugin +3. Review issues and click "Fix All" to auto-rename + +**Building the plugin:** +```bash +cd tools/base-ui-lint +npm install +npm run build +``` +Then import `tools/base-ui-lint/manifest.json` in Figma → Plugins → Development. + +**Rule files:** `tools/base-ui-lint/rules/*.json` + +### 2. Token System + +Tokens are exported natively from Figma Variables in W3C DTCG format. + +**Token sources:** +- `tokens/figma/origin/` — Origin design system tokens (Dark, Light, Value) +- `tokens/figma/baseline/` — Baseline/primitive tokens + +**Build tokens:** +```bash +npm run tokens:build +``` + +Outputs to `src/tokens/_variables.scss`. + +### 3. Icon System (`src/components/Icon/`) + +213 vendored icons from Central Icons. Icons are extracted from `@central-icons-react` packages and committed to the repo, so consumers do not need a `CENTRAL_LICENSE_KEY`. + +**Key files:** +- `CentralIcon.tsx` — Main component +- `icon-registry.ts` — Generated registry (do not edit directly) +- `icons/` — Vendored icon `.mjs` and `.d.ts` files +- `scripts/extract-icons.mjs` — Single source of truth for which icons to vendor + +**Usage:** +```tsx +import { CentralIcon } from '@/components/Icon'; + + + +``` + +**Adding/updating icons:** +```bash +npm run icons:extract # Regenerate vendored files + registry +``` + +Edit the `SECTIONS` array in `scripts/extract-icons.mjs` to add new icons. The script copies files from `node_modules`, strips sourcemaps, generates `icon-registry.ts`, and validates. + +**Packages (devDependencies — only needed for extraction):** +- `@central-icons-react/round-outlined-radius-3-stroke-1.5` +- `@central-icons-react/round-filled-radius-3-stroke-1.5` +- `@central-icons-react/round-outlined-radius-0-stroke-1.5` + +### 4. Analytics Context (`src/components/Analytics/`) + +Opt-in interaction tracking for all interactive primitives. Products provide a single `AnalyticsHandler` via `AnalyticsProvider`; components emit structured `InteractionInfo` when an `analyticsName` prop is set. + +**Shared hooks:** +- `useTrackedCallback` — wraps click, change, submit, select callbacks +- `useTrackedOpenChange` — wraps overlay open/close with duration tracking + +**Instrumented components:** Button, Form, Dialog, AlertDialog, Menu, Popover, Command, ContextMenu, Select, Switch, Checkbox.Group, Radio.Group, Tabs, Toggle, ToggleGroup, Combobox, Accordion, Table, NavigationMenu, Sidebar, Pagination, Menu.Item, Command.Item + +If no provider or `analyticsName` is present, the hooks are no-ops — zero cost for products that don't use analytics. + +--- + +## Project Structure + +``` +origin/ +├── src/ +│ ├── app/ # Next.js app +│ │ ├── globals.scss # Global styles + icon stroke CSS +│ │ ├── layout.tsx +│ │ └── page.tsx +│ ├── components/ +│ │ └── Icon/ # CentralIcon system +│ │ ├── icons/ # Vendored icon files (generated) +│ │ └── icon-registry.ts # Icon registry (generated) +│ ├── lib/ +│ │ └── dev-warn.ts # Dev-only warning utility +│ └── tokens/ +│ ├── _variables.scss # Generated from Figma tokens +│ └── _mixins.scss # SCSS mixins +├── tokens/ +│ └── figma/ +│ ├── origin/ # Origin tokens (Dark, Light, Value) +│ └── baseline/ # Baseline tokens +├── tools/ +│ └── base-ui-lint/ # Figma lint plugin +│ ├── rules/ # 37 component rule files +│ ├── src/ # Plugin source +│ └── dist/ # Built plugin +├── scripts/ +│ ├── build-tokens.js # Token transformation script +│ └── extract-icons.mjs # Icon vendoring + registry generation +└── CONTEXT.md # This file +``` + +--- + +## Key Decisions Made + +### 1. Base UI Over Custom Implementation + +Base UI provides: +- Accessibility built-in +- Keyboard navigation +- ARIA attributes +- Focus management +- Compound component patterns + +We style Base UI components with Figma-extracted CSS. + +### 2. Vendored Icon Registry + +Icons are vendored from `@central-icons-react` into the repo: +- Consumers do not need a `CENTRAL_LICENSE_KEY` to install Origin +- `scripts/extract-icons.mjs` is the single source of truth for which icons to include +- `icon-registry.ts` and `icons/` are generated — do not edit directly +- Tree-shaking still removes unused icons from bundle + +### 3. Figma Structure = Base UI Structure + +The lint plugin ensures Figma components match Base UI's anatomy: +- `Accordion.Item` in Figma → `Accordion.Item` in code +- `Panel` frame in Figma → `Accordion.Panel` in code + +This makes the Figma-to-code translation trivial. + +### 4. Separate AlertDialog from Dialog + +Base UI has distinct `Dialog` and `AlertDialog` components with different requirements: +- Dialog: Description optional +- AlertDialog: Description required (for accessibility) + +### 5. Structural Nodes Only + +The lint plugin only matches frames/groups/components, not text nodes. This prevents false positives from Figma's auto-naming of text layers. + +--- + +## Workflow + +### For Each Component + +1. **Design in Figma** with proper frame structure +2. **Run Base UI Lint Plugin** to validate/fix structure +3. **Copy CSS** from Figma Dev Mode (already tokenized) +4. **Create React component** using Base UI + copied CSS +5. **Done** — no generation step, no spec files + +### Example: Accordion + +**Figma structure after linting:** +``` +Accordion / Item +├── Header +│ └── Trigger +│ ├── Title (text) +│ └── Icon +└── Panel + └── Content (text) +``` + +**React component:** +```tsx +import { Accordion } from '@base-ui-components/react/accordion'; +import styles from './Accordion.module.scss'; + + + + + + {title} + + + + + {content} + + + +``` + +--- + +## Pending / Next Steps + +1. **Build first component end-to-end** — Use the lint plugin + Base UI + Figma CSS workflow +2. **Test the full flow** — Validate that the approach works in practice +3. **Add Storybook stories** — For component documentation +4. **CI/CD setup** — Token validation, type checking +5. **CI/CD setup** — `CENTRAL_LICENSE_KEY` in secrets for icon extraction if needed + +--- + +## Commands + +```bash +# Development +npm run dev # Start Next.js dev server +npm run storybook # Start Storybook + +# Build +npm run build # Build Next.js +npm run tokens:build # Transform Figma tokens to SCSS +npm run icons:extract # Vendor icons + regenerate registry + +# Lint plugin +cd tools/base-ui-lint +npm run build # Build the Figma plugin +``` + +--- + +## Important Notes + +### Central Icons License + +The `@central-icons-react` packages are `devDependencies` used only for icon extraction (`npm run icons:extract`). Developers running extraction need `CENTRAL_LICENSE_KEY` set for `npm install`. Consumers of `@lightsparkdev/origin` do not need the key — icons are vendored into the repo. + +### Base UI Package Rename + +Base UI was recently renamed: +- Old: `@base-ui-components/react` +- New: `@base-ui/react` + +We're currently using the old package name. Consider updating when stable. + +### TypeScript + +The `tools/` directory is excluded from the main tsconfig since it has Figma-specific types. + +--- + +## Related Files + +- **Figma Design System**: Set `FIGMA_FILE_KEY` in `.env.local` — see `.env.example` +- **Base UI Docs**: https://base-ui.com/react/components + +--- + +## Session History + +This project was set up in a conversation that: + +1. Analyzed origin v1's complexity and identified pain points +2. Proposed Base UI + Figma CSS as a simpler approach +3. Built the Base UI lint plugin with 100% component coverage +4. Set up the token system (origin + baseline) +5. Ported the complete icon system +6. Fixed several plugin bugs (variant handling, text node matching) + +The project is ready for building the first real component using the new workflow. diff --git a/packages/origin/LICENSE b/packages/origin/LICENSE new file mode 100644 index 000000000..60976825c --- /dev/null +++ b/packages/origin/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2025 Lightspark Group, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/packages/origin/README.md b/packages/origin/README.md new file mode 100644 index 000000000..eb931a16a --- /dev/null +++ b/packages/origin/README.md @@ -0,0 +1,176 @@ +# Origin Design System + +A design system built on **Base UI** with direct **Figma-to-code** styling. + +## Philosophy + +- **Base UI** handles behavior, accessibility, and keyboard navigation +- **Figma Dev Mode** provides tokenized CSS (copy directly) +- **Minimal transformation** = minimal drift + +## Quick Start + +```bash +npm install --legacy-peer-deps +npm run dev +``` + +## Structure + +``` +src/ +├── components/ # React components +│ └── Icon/ # CentralIcon system +├── tokens/ # Generated SCSS variables +└── app/ # Next.js app + +tools/ +├── base-ui-lint/ # Figma structure validation plugin +└── figma-styles/ # Internal Figma style sync (requires credentials) + +tokens/ +└── figma/ # Raw Figma token exports + ├── origin/ # Origin tokens + └── baseline/ # Baseline tokens +``` + +## Component Workflow + +1. **Design** in Figma with Base UI-compatible frame structure +2. **Validate** with the Base UI Lint Plugin +3. **Copy CSS** from Figma Dev Mode +4. **Implement** with Base UI + SCSS modules + +## Figma Lint Plugin + +```bash +cd tools/base-ui-lint +npm run build +``` + +Import in Figma → Plugins → Development → `manifest.json` + +Validates component structure against Base UI's expected anatomy. + +## Icons + +```tsx +import { CentralIcon } from '@/components/Icon'; + + +``` + +213 vendored icons from Central Icons. Edit `scripts/extract-icons.mjs` to add icons, then run `npm run icons:extract`. + +## Tokens + +Color and spacing tokens are built from exported Figma variables (`npm run tokens:build`). Typography mixins (`_text-styles.scss`) and shadow variables (`_effects.scss`) are generated from an internal Figma file and committed to the repo — external contributors don't need to regenerate them. Don't edit these generated files by hand. + +## Scripts + +| Command | Description | +|---------|-------------| +| `npm run dev` | Start development server | +| `npm run build` | Production build | +| `npm run storybook` | Start Storybook | +| `npm run tokens:build` | Build tokens from Figma exports | +| `npm run icons:extract` | Vendor icons and regenerate registry | +| `npm run test` | Playwright component tests | +| `npm run test:unit` | Vitest unit tests | +| `npm run test:all` | Run both test suites | +| `npm run lint` | Run ESLint | + +Internal maintainers with Figma credentials also have `figma:styles` and `figma:node` for syncing styles from the design file. + +## Using as a Package + +### Installation + +```bash +npm install @lightsparkdev/origin sass +``` + +Or for local development: + +```json +{ "dependencies": { "@lightsparkdev/origin": "file:../origin" } } +``` + +### Next.js Configuration + +```ts +// next.config.ts +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + transpilePackages: ['@lightsparkdev/origin'], +}; + +export default nextConfig; +``` + +### Import Styles + +```ts +import "@lightsparkdev/origin/styles.css"; +``` + +### Copy Fonts + +```bash +cp -r node_modules/@lightsparkdev/origin/public/fonts/ public/fonts/ +``` + +### Usage + +```tsx +import { Button, Input, Field } from '@lightsparkdev/origin'; +``` + +### Advanced: SCSS Token Imports (Optional) + +If you need Origin mixins in your app SCSS files, configure Sass package imports: + +```ts +// next.config.ts +import type { NextConfig } from "next"; +import * as sass from "sass"; + +const nextConfig: NextConfig = { + transpilePackages: ['@lightsparkdev/origin'], + sassOptions: { + importers: [new sass.NodePackageImporter()], + }, +}; + +export default nextConfig; +``` + +Then use `pkg:` imports: + +```scss +@use 'pkg:@lightsparkdev/origin/tokens/text-styles' as *; +``` + +For full setup details, see [Using Origin in Your App](docs/using-origin-in-your-app.md). + +## Typography + +Suisse Intl uses font metric overrides for precise line-height control: + +```scss +@font-face { + font-family: 'Suisse Intl'; + ascent-override: 81%; + descent-override: 19%; + line-gap-override: 0%; +} +``` + +These values are applied to all weights (Regular, Book, Medium) in `_fonts.scss`. Consuming apps should import Origin's fonts for correct input rendering. Without the font, the system falls back to `system-ui`. + +## Documentation + +- `docs/using-origin-in-your-app.md` — Token/font setup for consuming apps +- `CONTEXT.md` — Full project context and history +- `.cursor/rules/` — Auto-injected context for AI assistants diff --git a/packages/origin/eslint.config.mjs b/packages/origin/eslint.config.mjs new file mode 100644 index 000000000..2e0c9ebf2 --- /dev/null +++ b/packages/origin/eslint.config.mjs @@ -0,0 +1,29 @@ +import reactLib from "@lightsparkdev/eslint-config/react-lib"; + +export default [ + ...reactLib, + { + ignores: [ + "node_modules/", + ".next/", + "dist/", + "playwright-report/", + "test-results/", + ".cache/", + "tools/", + "scripts/", + "playwright/", + "storybook-static/", + // Test and story files live inside src/ but are excluded from tsconfig.json, + // which breaks type-aware eslint rules. Ignore them from linting. + "**/*.test.tsx", + "**/*.test.ts", + "**/*.unit.test.tsx", + "**/*.unit.test.ts", + "**/*.test-stories.tsx", + "**/*.stories.tsx", + // Dev-only Next.js app + "src/app/", + ], + }, +]; diff --git a/packages/origin/next.config.js b/packages/origin/next.config.js new file mode 100644 index 000000000..0bdd90f74 --- /dev/null +++ b/packages/origin/next.config.js @@ -0,0 +1,10 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + // Enable SCSS + sassOptions: { + includePaths: ['./src/tokens'], + }, +}; + +export default nextConfig; + diff --git a/packages/origin/package.json b/packages/origin/package.json new file mode 100644 index 000000000..c07d73da8 --- /dev/null +++ b/packages/origin/package.json @@ -0,0 +1,113 @@ +{ + "name": "@lightsparkdev/origin", + "version": "0.14.0", + "publishConfig": { + "access": "public" + }, + "description": "Origin Design System v2 - Base UI + Figma-first approach", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/lightsparkdev/webdev.git", + "directory": "js/packages/origin" + }, + "type": "module", + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./styles.css": "./dist/styles.css", + "./tokens/*": "./src/tokens/*" + }, + "files": [ + "dist/", + "src/components/", + "src/tokens/", + "src/lib/", + "src/index.ts", + "public/fonts/", + "skills/", + "README.md" + ], + "scripts": { + "build": "yarn build:styles", + "build:styles": "sass src/styles/public.scss dist/styles.css --no-source-map", + "build:watch": "sass --watch src/styles/public.scss dist/styles.css --no-source-map", + "clean": "rm -rf dist", + "dev": "next dev", + "format": "prettier src --check", + "format:fix": "prettier src --write", + "lint": "eslint src/ && stylelint 'src/**/*.scss'", + "lint:fix": "eslint --fix src/ && stylelint --fix 'src/**/*.scss'", + "lint:styles": "stylelint 'src/**/*.scss'", + "lint:watch": "esw src/ -w --ext .ts,.tsx --color", + "package:checks": "publint && attw --pack . --ignore-rules cjs-resolves-to-esm internal-resolution-error --exclude-entrypoints ./styles.css", + "storybook": "storybook dev -p 6006", + "build-sb": "echo 'Origin storybook requires @storybook/nextjs — run locally with: yarn storybook'", + "test": "vitest run", + "test:ct": "playwright test -c playwright-ct.config.ts", + "test:ct:ui": "playwright test -c playwright-ct.config.ts --ui", + "test:unit": "vitest run", + "test:unit:watch": "vitest", + "tokens:build": "node scripts/build-tokens.js", + "icons:extract": "node scripts/extract-icons.mjs", + "types": "tsc", + "types:watch": "tsc --watch", + "check:baseui": "node scripts/check-baseui-version.js", + "prepack": "yarn build:styles" + }, + "dependencies": { + "@base-ui/react": "^1.1.0", + "@base-ui/utils": "^0.2.3", + "@tanstack/react-table": "^8.21.3", + "ajv": "^8.18.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "next": ">=13", + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "sass": { + "optional": true + } + }, + "devDependencies": { + "@arethetypeswrong/cli": "^0.17.4", + "@axe-core/playwright": "^4.11.0", + "@central-icons-react/round-filled-radius-3-stroke-1.5": "^1.1.153", + "@central-icons-react/round-outlined-radius-0-stroke-1.5": "^1.1.153", + "@central-icons-react/round-outlined-radius-3-stroke-1.5": "^1.1.153", + "@lightsparkdev/eslint-config": "*", + "@lightsparkdev/tsconfig": "0.0.1", + "@playwright/experimental-ct-react": "^1.57.0", + "@storybook/nextjs": "^10.1.10", + "@storybook/react": "^10.1.10", + "@testing-library/dom": "^9.2.0", + "@testing-library/jest-dom": "^6.1.2", + "@testing-library/react": "^14.0.0", + "@types/node": "^20.2.5", + "@types/react": "^18.2.12", + "@types/react-dom": "^18.0.0", + "@vitejs/plugin-react": "^4.0.1", + "dotenv": "^16.3.1", + "eslint": "^9.0.0", + "eslint-watch": "^8.0.0", + "jsdom": "^25.0.1", + "match-sorter": "^8.2.0", + "next": "^13.5.10", + "prettier": "3.0.3", + "publint": "^0.3.9", + "react": "^18.2.0", + "react-dom": "^18.1.0", + "sass": "^1.80.0", + "storybook": "^10.1.10", + "stylelint": "^17.1.1", + "stylelint-config-standard-scss": "^17.0.0", + "typescript": "^5.6.2", + "vitest": "^3.1.4" + }, + "engines": { + "node": ">=18" + } +} diff --git a/packages/origin/playwright-ct.config.ts b/packages/origin/playwright-ct.config.ts new file mode 100644 index 000000000..f1dda4fd8 --- /dev/null +++ b/packages/origin/playwright-ct.config.ts @@ -0,0 +1,45 @@ +import { defineConfig, devices } from '@playwright/experimental-ct-react'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + testDir: './src/components', + testMatch: '**/*.test.tsx', + testIgnore: ['**/*.unit.test.tsx'], + snapshotDir: './__snapshots__', + timeout: 10000, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + ctPort: 3100, + trace: 'on-first-retry', + ctViteConfig: { + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + css: { + preprocessorOptions: { + scss: { + api: 'modern-compiler', + // Mirror next.config.js sassOptions.includePaths + loadPaths: [path.resolve(__dirname, './src/tokens')], + }, + }, + }, + }, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); + diff --git a/packages/origin/playwright/index.html b/packages/origin/playwright/index.html new file mode 100644 index 000000000..71b26ad9d --- /dev/null +++ b/packages/origin/playwright/index.html @@ -0,0 +1,13 @@ + + + + + + Playwright Component Testing + + +
+ + + + diff --git a/packages/origin/playwright/index.tsx b/packages/origin/playwright/index.tsx new file mode 100644 index 000000000..3891a50f2 --- /dev/null +++ b/packages/origin/playwright/index.tsx @@ -0,0 +1,2 @@ +import '../src/app/globals.scss'; + diff --git a/packages/origin/public/.gitkeep b/packages/origin/public/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/origin/public/fonts/SuisseIntl-Book.woff2 b/packages/origin/public/fonts/SuisseIntl-Book.woff2 new file mode 100644 index 000000000..b43587ca4 Binary files /dev/null and b/packages/origin/public/fonts/SuisseIntl-Book.woff2 differ diff --git a/packages/origin/public/fonts/SuisseIntl-Medium.woff2 b/packages/origin/public/fonts/SuisseIntl-Medium.woff2 new file mode 100644 index 000000000..6089a9f6e Binary files /dev/null and b/packages/origin/public/fonts/SuisseIntl-Medium.woff2 differ diff --git a/packages/origin/public/fonts/SuisseIntl-Regular.woff2 b/packages/origin/public/fonts/SuisseIntl-Regular.woff2 new file mode 100644 index 000000000..afdcff979 Binary files /dev/null and b/packages/origin/public/fonts/SuisseIntl-Regular.woff2 differ diff --git a/packages/origin/public/fonts/SuisseIntlMono-Regular-WebXL.woff2 b/packages/origin/public/fonts/SuisseIntlMono-Regular-WebXL.woff2 new file mode 100644 index 000000000..894e80287 Binary files /dev/null and b/packages/origin/public/fonts/SuisseIntlMono-Regular-WebXL.woff2 differ diff --git a/packages/origin/scripts/build-tokens.js b/packages/origin/scripts/build-tokens.js new file mode 100644 index 000000000..c945139d2 --- /dev/null +++ b/packages/origin/scripts/build-tokens.js @@ -0,0 +1,214 @@ +// Transforms Figma DTCG token export → SCSS variables + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const TOKENS_DIR = path.join(__dirname, '../tokens/figma'); +const OUTPUT_FILE = path.join(__dirname, '../src/tokens/_variables.scss'); + +function findTokenFiles(dir, files = []) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + findTokenFiles(fullPath, files); + } else if (entry.name.endsWith('.json') && !entry.name.startsWith('.')) { + files.push(fullPath); + } + } + + return files; +} + +function extractTokens(obj, prefix = '') { + const tokens = []; + + for (const [key, value] of Object.entries(obj)) { + if (key.startsWith('$')) continue; + + const tokenPath = prefix ? `${prefix}/${key}` : key; + + if (value && typeof value === 'object' && '$value' in value) { + tokens.push({ name: tokenPath, value: value.$value, type: value.$type }); + } else if (value && typeof value === 'object') { + tokens.push(...extractTokens(value, tokenPath)); + } + } + + return tokens; +} + +function figmaColorToCSS(colorObj) { + if (typeof colorObj === 'string') return colorObj; + + if (colorObj && typeof colorObj === 'object') { + const { components, alpha, hex } = colorObj; + + if (alpha >= 0.999 && hex) return hex; + + if (components && components.length >= 3) { + const r = Math.round(components[0] * 255); + const g = Math.round(components[1] * 255); + const b = Math.round(components[2] * 255); + const a = alpha ?? 1; + + if (a >= 0.999) return `rgb(${r}, ${g}, ${b})`; + return `rgba(${r}, ${g}, ${b}, ${a.toFixed(2)})`; + } + + if (hex) return hex; + } + + return String(colorObj); +} + +function isFontFamilyToken(name) { + const lower = name.toLowerCase(); + return lower.includes('font-family') || lower.includes('font/family'); +} + +function needsPxUnits(tokenName) { + const pxPatterns = [ + 'spacing/', + 'corner-radius/', + 'stroke/', + 'font/size', + 'font/leading', + 'font/tracking', + 'max-width/', + 'screens/', + ]; + const lower = tokenName.toLowerCase(); + return pxPatterns.some(pattern => lower.includes(pattern)); +} + +function quoteFontFamily(value) { + return `"${String(value).replace(/"/g, '\\"')}"`; +} + +function toCSSValue(token) { + const { value, type, name } = token; + + if (typeof value === 'string' && value.startsWith('{') && value.endsWith('}')) { + const ref = value.slice(1, -1).replace(/\./g, '-').replace(/\//g, '-'); + return `var(--${ref})`; + } + + switch (type) { + case 'color': + return figmaColorToCSS(value); + case 'dimension': + return typeof value === 'number' ? `${value}px` : value; + case 'number': + return needsPxUnits(name) ? `${value}px` : String(value); + case 'fontFamily': + return quoteFontFamily(value); + case 'fontWeight': + return String(value); + default: + if (isFontFamilyToken(name)) return quoteFontFamily(value); + if (typeof value === 'number' && needsPxUnits(name)) return `${value}px`; + return String(value); + } +} + +function toVarName(tokenPath) { + return `--${tokenPath.replace(/\//g, '-').replace(/\s+/g, '-').toLowerCase()}`; +} + +function build() { + console.log('Building tokens from Figma export...\n'); + + const tokenFiles = findTokenFiles(TOKENS_DIR); + + if (tokenFiles.length === 0) { + console.error('Error: No token files found in', TOKENS_DIR); + process.exit(1); + } + + const baselineTokens = new Map(); + const originPrimitives = new Map(); + const lightTokens = new Map(); + const darkTokens = new Map(); + + for (const filePath of tokenFiles) { + const relativePath = path.relative(TOKENS_DIR, filePath); + const content = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + const tokens = extractTokens(content); + + console.log(` ${relativePath}: ${tokens.length} tokens`); + + for (const token of tokens) { + if (relativePath.startsWith('baseline/')) { + baselineTokens.set(token.name, token); + } else if (relativePath.includes('Light.tokens')) { + lightTokens.set(token.name, token); + } else if (relativePath.includes('Dark.tokens')) { + darkTokens.set(token.name, token); + } else { + originPrimitives.set(token.name, token); + } + } + } + + let scss = `// Auto-generated — do not edit. Run: yarn tokens:build + +:root { +`; + + function writeTokenGroup(tokens) { + if (tokens.size === 0) return ''; + + let output = ''; + for (const [name, token] of tokens) { + const varName = toVarName(token.name); + const value = toCSSValue(token); + output += ` ${varName}: ${value};\n`; + } + return output; + } + + scss += writeTokenGroup(baselineTokens); + scss += writeTokenGroup(originPrimitives); + scss += writeTokenGroup(lightTokens); + + scss += `} + +[data-theme="dark"], +.dark { +`; + + for (const [name, token] of darkTokens) { + scss += ` ${toVarName(name)}: ${toCSSValue(token)};\n`; + } + + scss += `} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { +`; + + for (const [name, token] of darkTokens) { + scss += ` ${toVarName(name)}: ${toCSSValue(token)};\n`; + } + + scss += ` } +} +`; + + fs.mkdirSync(path.dirname(OUTPUT_FILE), { recursive: true }); + fs.writeFileSync(OUTPUT_FILE, scss); + + const totalTokens = baselineTokens.size + originPrimitives.size + lightTokens.size + darkTokens.size; + console.log(`\nGenerated ${OUTPUT_FILE}`); + console.log(` ${totalTokens} total tokens`); + console.log(` - Baseline: ${baselineTokens.size}`); + console.log(` - Primitives: ${originPrimitives.size}`); + console.log(` - Light mode: ${lightTokens.size}`); + console.log(` - Dark mode: ${darkTokens.size}`); +} + +build(); diff --git a/packages/origin/scripts/check-baseui-version.js b/packages/origin/scripts/check-baseui-version.js new file mode 100644 index 000000000..1fb26919a --- /dev/null +++ b/packages/origin/scripts/check-baseui-version.js @@ -0,0 +1,61 @@ +#!/usr/bin/env node + +/** + * Checks if the Base UI version has changed since we last synced our utilities. + * + * Usage: yarn check:baseui + */ + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const UTILS_FILE = path.join(__dirname, '../src/lib/base-ui-utils.ts'); +const BASE_UI_PKG = path.join(__dirname, '../node_modules/@base-ui-components/react/package.json'); + +// Files we copied from Base UI +const COPIED_FILES = [ + 'esm/utils/getStateAttributesProps.js', + 'esm/utils/createBaseUIEventDetails.js', + 'esm/utils/reason-parts.js', +]; + +function getInstalledVersion() { + const pkg = JSON.parse(fs.readFileSync(BASE_UI_PKG, 'utf-8')); + return pkg.version; +} + +function getSyncedVersion() { + const content = fs.readFileSync(UTILS_FILE, 'utf-8'); + const match = content.match(/@baseui-version\s+([\d.a-z-]+)/); + return match ? match[1] : null; +} + +function main() { + const installed = getInstalledVersion(); + const synced = getSyncedVersion(); + + console.log('Base UI Version Check'); + console.log('====================='); + console.log(`Installed: ${installed}`); + console.log(`Synced: ${synced || 'unknown'}`); + console.log(''); + + if (installed !== synced) { + console.log('WARNING: Version mismatch!'); + console.log(''); + console.log('Review these files for changes:'); + COPIED_FILES.forEach(file => { + console.log(` node_modules/@base-ui-components/react/${file}`); + }); + console.log(''); + console.log('After syncing, update @baseui-version in src/lib/base-ui-utils.ts'); + process.exit(1); + } else { + console.log('OK: Versions match'); + } +} + +main(); diff --git a/packages/origin/scripts/extract-icons.mjs b/packages/origin/scripts/extract-icons.mjs new file mode 100644 index 000000000..8011a6b02 --- /dev/null +++ b/packages/origin/scripts/extract-icons.mjs @@ -0,0 +1,505 @@ +/** + * Extract and vendor Central Icons. + * + * This script is the single source of truth for which icons Origin uses. + * It copies icon files from @central-icons-react packages (devDependencies) + * into src/components/Icon/icons/ and generates icon-registry.ts. + * + * Usage: + * yarn icons:extract (requires @central-icons-react packages installed) + * + * To add a new icon: + * 1. Add an entry to SECTIONS below + * 2. Run: yarn icons:extract + * 3. Commit the updated icons/ directory and icon-registry.ts + */ + +import { existsSync, mkdirSync, cpSync, rmSync, writeFileSync, readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, '..'); +const ICONS_DIR = join(ROOT, 'src', 'components', 'Icon', 'icons'); +const REGISTRY_PATH = join(ROOT, 'src', 'components', 'Icon', 'icon-registry.ts'); + +// ── Package mapping ────────────────────────────────────────── + +const PACKAGES = { + outlined: '@central-icons-react/round-outlined-radius-3-stroke-1.5', + sharp: '@central-icons-react/round-outlined-radius-0-stroke-1.5', + filled: '@central-icons-react/round-filled-radius-3-stroke-1.5', +}; + +// ── Icon configuration ─────────────────────────────────────── +// +// Each section: { section, icons } +// - section: Comment label used in the generated registry +// - icons: Array of { name, variant?, exportAs? } +// +// Icon fields: +// - name: Component name in Central Icons +// - variant: 'outlined' (default) | 'sharp' | 'filled' +// - exportAs: Name exported from Origin (defaults to name) + +const SECTIONS = [ + { + section: 'Arrows & Navigation', + icons: [ + { name: 'IconArrow', variant: 'sharp' }, + { name: 'IconArrowDown', variant: 'sharp' }, + { name: 'IconArrowDownLeft', variant: 'sharp' }, + { name: 'IconArrowDownRight', variant: 'sharp' }, + { name: 'IconArrowLeft', variant: 'sharp' }, + { name: 'IconArrowRight', variant: 'sharp' }, + { name: 'IconArrowUp', variant: 'sharp' }, + { name: 'IconArrowUpLeft', variant: 'sharp' }, + { name: 'IconArrowUpRight', variant: 'sharp' }, + { name: 'IconRedirectArrow', variant: 'sharp' }, + { name: 'IconArrowBoxRight' }, + { name: 'IconArrowDownSquare' }, + { name: 'IconArrowDownWall' }, + { name: 'IconArrowInbox' }, + { name: 'IconArrowLeftSquare' }, + { name: 'IconArrowLoopDownLeft' }, + { name: 'IconArrowOutOfBox' }, + { name: 'IconArrowRightSquare' }, + { name: 'IconArrowUpSquare' }, + { name: 'IconArrowUpWall' }, + { name: 'IconArrowsRepeat' }, + { name: 'IconArrowsRepeatCircle' }, + { name: 'IconRotate360Left' }, + { name: 'IconRotate360Right' }, + ], + }, + { + section: 'Chevrons', + icons: [ + { name: 'IconChevronBottom', variant: 'sharp' }, + { name: 'IconChevronDownSmall', variant: 'sharp' }, + { name: 'IconChevronGrabberVertical', variant: 'sharp' }, + { name: 'IconChevronLeft', variant: 'sharp' }, + { name: 'IconChevronLeftSmall', variant: 'sharp' }, + { name: 'IconChevronRight', variant: 'sharp' }, + { name: 'IconChevronRightSmall', variant: 'sharp' }, + { name: 'IconChevronTop', variant: 'sharp' }, + { name: 'IconChevronTopSmall', variant: 'sharp' }, + ], + }, + { + section: 'Actions & UI', + icons: [ + { name: 'IconAdjustPhoto' }, + { name: 'IconAt' }, + { name: 'IconAutoCrop' }, + { name: 'IconBarsThree2' }, + { name: 'IconBell' }, + { name: 'IconBellOff' }, + { name: 'IconBlackpoint' }, + { name: 'IconBank' }, + { name: 'IconBuildings' }, + { name: 'IconBrackets1' }, + { name: 'IconBrokenHeart' }, + { name: 'IconBrowserTabs' }, + { name: 'IconBubble3' }, + { name: 'IconBubbleWideSparkle' }, + { name: 'IconCalendarDays' }, + { name: 'IconCheckmark2' }, + { name: 'IconCheckmark2Small' }, + { name: 'IconCircleCheck' }, + { name: 'IconCircleCheck', variant: 'filled', exportAs: 'IconCircleCheckFilled' }, + { name: 'IconCircleInfo' }, + { name: 'IconCircleInfo', variant: 'filled', exportAs: 'IconCircleInfoFilled' }, + { name: 'IconCirclePlus' }, + { name: 'IconCircleQuestionmark' }, + { name: 'IconCircleX', variant: 'filled' }, + { name: 'IconClipboard2' }, + { name: 'IconClipboard2Sparkle' }, + { name: 'IconCoinsAdd' }, + { name: 'IconCoinsAdd', variant: 'filled', exportAs: 'IconCoinsAddFilled' }, + { name: 'IconClock' }, + { name: 'IconCmdBox' }, + { name: 'IconConnectors1' }, + { name: 'IconConnectors2' }, + { name: 'IconConsoleSparkle' }, + { name: 'IconCrossLarge' }, + { name: 'IconCrossMedium' }, + { name: 'IconCrossSmall' }, + { name: 'IconCryptoWallet' }, + { name: 'IconDevices', variant: 'filled' }, + { name: 'IconDiamondShine' }, + { name: 'IconDifferenceIgnored' }, + { name: 'IconDifferenceModified' }, + { name: 'IconDiscoBall' }, + { name: 'IconDotGrid1x3Horizontal' }, + { name: 'IconDotGrid1x3HorizontalTight' }, + { name: 'IconDotGrid1x3Vertical' }, + { name: 'IconDotGrid1x3VerticalTight' }, + { name: 'IconDotGrid2x3' }, + { name: 'IconDotGrid3x3' }, + { name: 'IconExclamationTriangle' }, + { name: 'IconExclamationTriangle', variant: 'filled', exportAs: 'IconExclamationTriangleFilled' }, + { name: 'IconEyeOpen' }, + { name: 'IconEyeSlash' }, + { name: 'IconEyeSlash2' }, + { name: 'IconFileArrowLeftIn' }, + { name: 'IconFileArrowLeftOut' }, + { name: 'IconFileArrowRightOut' }, + { name: 'IconFileBend' }, + { name: 'IconFilter2' }, + { name: 'IconFolderAddRight' }, + { name: 'IconFingerPrint1' }, + { name: 'IconFormPyramide' }, + { name: 'IconForYou' }, + { name: 'IconFullScreen' }, + { name: 'IconGlobe2' }, + { name: 'IconHeart2' }, + { name: 'IconHeart2', variant: 'filled', exportAs: 'IconHeart2Filled' }, + { name: 'IconHome' }, + { name: 'IconImport2' }, + { name: 'IconInitiatives' }, + { name: 'IconInvite' }, + { name: 'IconKey2' }, + { name: 'IconLayoutColumn' }, + { name: 'IconLayoutLeft' }, + { name: 'IconLayoutRight' }, + { name: 'IconListSparkle' }, + { name: 'IconLiveActivity' }, + { name: 'IconLiveFull' }, + { name: 'IconLoader' }, + { name: 'IconLock' }, + { name: 'IconMagnifyingGlass2' }, + { name: 'IconMinusLarge' }, + { name: 'IconMinusSmall' }, + { name: 'IconMoon', variant: 'filled' }, + { name: 'IconMouse' }, + { name: 'IconOffline' }, + { name: 'IconOngoing' }, + { name: 'IconOngoing', variant: 'filled', exportAs: 'IconOngoingFilled' }, + { name: 'IconPaperclip1' }, + { name: 'IconPaperPlaneTopRight' }, + { name: 'IconPaperPlaneTopRight', variant: 'filled', exportAs: 'IconPaperPlaneTopRightFilled' }, + { name: 'IconPassport' }, + { name: 'IconPassword' }, + { name: 'IconPasswordStars' }, + { name: 'IconPencil' }, + { name: 'IconPencil2' }, + { name: 'IconPencil3' }, + { name: 'IconPencilAi' }, + { name: 'IconPeople2' }, + { name: 'IconPeople2', variant: 'filled', exportAs: 'IconPeople2Filled' }, + { name: 'IconPeopleAdd' }, + { name: 'IconPeopleAdd', variant: 'filled', exportAs: 'IconPeopleAddFilled' }, + { name: 'IconPeopleCircle' }, + { name: 'IconPeopleIdCard' }, + { name: 'IconPhone' }, + { name: 'IconPhoneDynamicIsland' }, + { name: 'IconPlusLarge' }, + { name: 'IconPlusSmall' }, + { name: 'IconPrompt' }, + { name: 'IconRandom' }, + { name: 'IconRemix' }, + { name: 'IconRemoveKeyframe' }, + { name: 'IconRepeat' }, + { name: 'IconRescueRing' }, + { name: 'IconRunShortcut' }, + { name: 'IconScanCode' }, + { name: 'IconSearchIntelligence' }, + { name: 'IconSearchlinesSparkle' }, + { name: 'IconSecretPhrase' }, + { name: 'IconSettingsGear1' }, + { name: 'IconSettingsGear2' }, + { name: 'IconShield' }, + { name: 'IconShield2' }, + { name: 'IconShieldKeyhole' }, + { name: 'IconSidebarSimpleLeftWide' }, + { name: 'IconSpacebar' }, + { name: 'IconSquareBehindSquare1' }, + { name: 'IconSquareBehindSquare6' }, + { name: 'IconSquareInfo' }, + { name: 'IconSquareArrowTopRight2' }, + { name: 'IconSquarePlus' }, + { name: 'IconSticker' }, + { name: 'IconSun', variant: 'filled' }, + { name: 'IconStop' }, + { name: 'IconStopCircle' }, + { name: 'IconTag' }, + { name: 'IconTarget' }, + { name: 'IconTelescope' }, + { name: 'IconTextToSpeach' }, + { name: 'IconThumbDownCurved' }, + { name: 'IconThumbUpCurved' }, + { name: 'IconTextareaDrag' }, + { name: 'IconTimeFlies' }, + { name: 'IconTimeslot' }, + { name: 'IconToggle' }, + { name: 'IconTrashCanSimple' }, + { name: 'IconTrashRounded' }, + { name: 'IconUnblur' }, + { name: 'IconUsbC' }, + { name: 'IconVariables' }, + { name: 'IconWallet1' }, + { name: 'IconWallet3' }, + { name: 'IconWeb3' }, + { name: 'IconWindowSparkle' }, + { name: 'IconWreathSimple' }, + ], + }, + { + section: 'AI & Sparkle', + icons: [ + { name: 'IconAgenticCoding' }, + { name: 'IconImagineAi' }, + { name: 'IconVibeCoding2' }, + { name: 'IconVisualIntelligence' }, + ], + }, + { + section: 'Voice', + icons: [ + { name: 'IconVoiceHigh' }, + { name: 'IconVoiceLow' }, + { name: 'IconVoiceMid' }, + { name: 'IconVoiceRecord' }, + { name: 'IconVoiceSettings' }, + { name: 'IconVoiceSparkle' }, + ], + }, + { + section: 'User & People', + icons: [ + { name: 'IconPeople' }, + { name: 'IconUserAdded' }, + { name: 'IconUserAddRight' }, + { name: 'IconUserBlock' }, + { name: 'IconUserDuo' }, + { name: 'IconUserEdit' }, + { name: 'IconUserGroup' }, + { name: 'IconUserRemove' }, + { name: 'IconUserRemoveRight' }, + { name: 'IconUserSettings' }, + ], + }, + { + section: 'Brands & Logos', + icons: [ + { name: 'IconAntigravity' }, + { name: 'IconApple' }, + { name: 'IconBitcoinLogo' }, + { name: 'IconClaudeai' }, + { name: 'IconCursor' }, + { name: 'IconEuropeanUnion' }, + { name: 'IconGemini' }, + { name: 'IconGithub' }, + { name: 'IconGrok' }, + { name: 'IconIsoOrg' }, + { name: 'IconLinear' }, + { name: 'IconLinkedin' }, + { name: 'IconNotion' }, + { name: 'IconOpenai' }, + { name: 'IconSlack' }, + { name: 'IconSupabase' }, + { name: 'IconTwitter' }, + { name: 'IconV0' }, + { name: 'IconVercel' }, + ], + }, +]; + +// Convenience aliases — reference existing icons, not extracted separately +const ALIASES = [ + { alias: 'IconChevronDown', target: 'IconChevronDownSmall', section: 'Chevrons' }, +]; + +// ── Helpers ────────────────────────────────────────────────── + +function resolvePackagePath(variant) { + const pkg = PACKAGES[variant]; + if (!pkg) throw new Error(`Unknown variant: ${variant}`); + const pkgPath = join(ROOT, 'node_modules', pkg); + if (!existsSync(pkgPath)) { + throw new Error( + `Package ${pkg} not found. Run yarn install with CENTRAL_LICENSE_KEY set.` + ); + } + return pkgPath; +} + +function copyIconFiles(srcDir, destDir) { + if (!existsSync(srcDir)) return false; + mkdirSync(destDir, { recursive: true }); + for (const file of ['index.mjs', 'index.d.ts']) { + const src = join(srcDir, file); + if (!existsSync(src)) continue; + if (file === 'index.mjs') { + const content = readFileSync(src, 'utf-8').replace( + /\n\/\/#\s*sourceMappingURL=.*$/m, + '' + ); + writeFileSync(join(destDir, file), content); + } else { + cpSync(src, join(destDir, file)); + } + } + return true; +} + +// ── Extract icons from node_modules ────────────────────────── + +function extractIcons() { + console.log('Extracting icons from @central-icons-react...\n'); + + const packagePaths = {}; + for (const [variant, pkg] of Object.entries(PACKAGES)) { + packagePaths[variant] = resolvePackagePath(variant); + console.log(` ${variant}: ${packagePaths[variant]}`); + } + + if (existsSync(ICONS_DIR)) rmSync(ICONS_DIR, { recursive: true }); + mkdirSync(ICONS_DIR, { recursive: true }); + + // CentralIconBase is needed for .d.ts type resolution + const baseSrc = join(packagePaths.outlined, 'CentralIconBase'); + if (copyIconFiles(baseSrc, join(ICONS_DIR, 'CentralIconBase'))) { + console.log('\n Copied CentralIconBase'); + } + + let extracted = 0; + let warnings = 0; + + for (const { icons } of SECTIONS) { + for (const icon of icons) { + const variant = icon.variant || 'outlined'; + const exportAs = icon.exportAs || icon.name; + const srcDir = join(packagePaths[variant], icon.name); + const destDir = join(ICONS_DIR, exportAs); + + if (copyIconFiles(srcDir, destDir)) { + extracted++; + } else { + console.warn(` WARN: ${icon.name} not found in ${PACKAGES[variant]}`); + warnings++; + } + } + } + + console.log(`\n Extracted ${extracted} icons`); + if (warnings > 0) console.warn(` ${warnings} warning(s)`); + return warnings === 0; +} + +// ── Generate icon-registry.ts ──────────────────────────────── + +function generateRegistry() { + const lines = [ + '/**', + ' * Auto-generated by scripts/extract-icons.mjs — do not edit.', + ' *', + ' * To add or update icons, edit the SECTIONS config in that script', + ' * and run: yarn icons:extract', + ' */', + '', + ]; + + // Imports — sorted alphabetically by exportAs within each section + for (const { section, icons } of SECTIONS) { + lines.push(`// ${section}`); + + const sorted = icons + .map((icon) => ({ + name: icon.name, + exportAs: icon.exportAs || icon.name, + })) + .sort((a, b) => a.exportAs.localeCompare(b.exportAs)); + + for (const { name, exportAs } of sorted) { + if (name === exportAs) { + lines.push(`import { ${name} } from './icons/${exportAs}';`); + } else { + lines.push( + `import { ${name} as ${exportAs} } from './icons/${exportAs}';` + ); + } + } + + lines.push(''); + } + + // Aliases + if (ALIASES.length > 0) { + for (const { alias, target } of ALIASES) { + lines.push(`const ${alias} = ${target};`); + } + lines.push(''); + } + + // ICON_REGISTRY + lines.push('export const ICON_REGISTRY = {'); + + for (const { section, icons } of SECTIONS) { + const exportNames = icons + .map((icon) => icon.exportAs || icon.name) + .concat( + ALIASES.filter((a) => a.section === section).map((a) => a.alias) + ) + .sort(); + + lines.push(` // ${section}`); + for (const name of exportNames) { + lines.push(` ${name},`); + } + lines.push(''); + } + + // Remove trailing blank line inside the object + if (lines[lines.length - 1] === '') lines.pop(); + + lines.push('} as const;'); + lines.push(''); + lines.push('export type CentralIconName = keyof typeof ICON_REGISTRY;'); + lines.push(''); + + writeFileSync(REGISTRY_PATH, lines.join('\n')); + console.log(`\n Generated ${REGISTRY_PATH}`); +} + +// ── Validate extracted icons ───────────────────────────────── + +function validateIcons() { + let errors = 0; + + for (const { icons } of SECTIONS) { + for (const icon of icons) { + const exportAs = icon.exportAs || icon.name; + const dir = join(ICONS_DIR, exportAs); + + for (const file of ['index.mjs', 'index.d.ts']) { + if (!existsSync(join(dir, file))) { + console.error(` MISSING: ${exportAs}/${file}`); + errors++; + } + } + } + } + + if (errors > 0) { + console.error(`\n Validation failed: ${errors} missing file(s)`); + } else { + console.log(' Validation passed'); + } + + return errors === 0; +} + +// ── Main ───────────────────────────────────────────────────── + +function main() { + const ok = extractIcons(); + generateRegistry(); + console.log(''); + const valid = validateIcons(); + console.log('\nDone.'); + if (!ok || !valid) process.exit(1); +} + +main(); diff --git a/packages/origin/scripts/patch-playwright-ct.js b/packages/origin/scripts/patch-playwright-ct.js new file mode 100644 index 000000000..915158cb1 --- /dev/null +++ b/packages/origin/scripts/patch-playwright-ct.js @@ -0,0 +1,93 @@ +/** + * Patches Playwright to support SCSS/Sass/Less imports in component tests. + * + * Problem: Playwright's ESM loader runs Babel on ALL non-node_modules files, + * including .scss/.sass/.less. Babel can't parse CSS preprocessor syntax, + * causing "Support for the experimental syntax 'decorators'" errors. + * + * Root cause chain: + * 1. esmLoader.js load() accepts format=null (unknown file types) + * 2. shouldTransform() returns true for any file outside node_modules + * 3. transformHook() runs Babel on the .scss file + * 4. Babel interprets @use as a decorator → SyntaxError + * + * Fix (two patches): + * A. esmLoader.js: Return empty module for CSS preprocessor files before + * they reach Babel. Tests don't need actual CSS — just a valid export. + * B. tsxTransform.js: Add .scss/.sass/.less to artifactExtensions so the + * Babel plugin strips these imports from test files during collection. + * + * This runs as a postinstall script. Safe to re-run (idempotent). + */ + +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const projectRoot = join(__dirname, '..'); +const nodeModules = join(projectRoot, 'node_modules'); + +const PREPROCESSOR_EXTENSIONS = ['.scss', '.sass', '.less']; +const SENTINEL = '/* patched-by-origin: css-preprocessor-support */'; + +// --------------------------------------------------------------------------- +// Patch A: esmLoader.js — skip CSS preprocessor files +// --------------------------------------------------------------------------- +function patchEsmLoader() { + const loaderPath = join(nodeModules, 'playwright', 'lib', 'transform', 'esmLoader.js'); + if (!existsSync(loaderPath)) return; + + let source = readFileSync(loaderPath, 'utf8'); + if (source.includes(SENTINEL)) return; // already patched + + // Insert a guard at the top of the load() function that returns an empty + // module for CSS preprocessor files. The guard goes right after the + // "async function load(moduleUrl, context, defaultLoad) {" line. + const target = 'async function load(moduleUrl, context, defaultLoad) {'; + if (!source.includes(target)) { + console.warn('patch-playwright-ct: could not find load() in esmLoader.js — skipping'); + return; + } + + const guard = `${target} + ${SENTINEL} + const _ext = moduleUrl.slice(moduleUrl.lastIndexOf('.')); + if ([${PREPROCESSOR_EXTENSIONS.map(e => `'${e}'`).join(', ')}].includes(_ext)) { + return { format: 'module', source: 'export default {};', shortCircuit: true }; + }`; + + source = source.replace(target, guard); + writeFileSync(loaderPath, source, 'utf8'); + console.log('Patched playwright esmLoader.js: CSS preprocessor files return empty module'); +} + +// --------------------------------------------------------------------------- +// Patch B: tsxTransform.js — add preprocessor extensions to artifact set +// --------------------------------------------------------------------------- +function patchTsxTransform() { + const transformPath = join( + nodeModules, '@playwright', 'experimental-ct-core', 'lib', 'tsxTransform.js' + ); + if (!existsSync(transformPath)) return; + + let source = readFileSync(transformPath, 'utf8'); + if (source.includes('.scss')) return; // already patched + + const target = '// CSS\n ".css"'; + if (!source.includes(target)) { + console.warn('patch-playwright-ct: could not find CSS entry in tsxTransform.js — skipping'); + return; + } + + const replacement = '// CSS\n ".css",\n ".scss",\n ".sass",\n ".less"'; + source = source.replace(target, replacement); + writeFileSync(transformPath, source, 'utf8'); + console.log('Patched playwright tsxTransform.js: added .scss/.sass/.less to artifactExtensions'); +} + +// --------------------------------------------------------------------------- +// Run both patches +// --------------------------------------------------------------------------- +patchEsmLoader(); +patchTsxTransform(); diff --git a/packages/origin/skills/origin/SKILL.md b/packages/origin/skills/origin/SKILL.md new file mode 100644 index 000000000..1f77eb928 --- /dev/null +++ b/packages/origin/skills/origin/SKILL.md @@ -0,0 +1,198 @@ +--- +name: origin +description: Use when building UI with @lightsparkdev/origin design system components. Covers setup, component APIs, icons, tokens, and patterns. +--- + +# Origin Design System + +Origin is a Figma-first React component library built on Base UI. It ships as raw TypeScript + SCSS source and requires a Next.js consumer with `transpilePackages`. + +## Required Setup + +### next.config.ts + +```ts +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + transpilePackages: ['@lightsparkdev/origin'], +}; + +export default nextConfig; +``` + +### Global styles (layout.tsx or app entry) + +```ts +import "@lightsparkdev/origin/styles.css"; +``` + +### Advanced SCSS token imports (optional) + +If you need Origin mixins in your own SCSS files, enable Sass package imports: + +```ts +import type { NextConfig } from "next"; +import * as sass from "sass"; + +const nextConfig: NextConfig = { + transpilePackages: ['@lightsparkdev/origin'], + sassOptions: { + importers: [new sass.NodePackageImporter()], + }, +}; + +export default nextConfig; +``` + +### Webpack alias caveat + +If adding a `resolve.alias` for `@lightsparkdev/origin`, always use the exact-match `$` suffix: + +```js +config.resolve.alias['@lightsparkdev/origin$'] = '/path/to/src/index.ts'; +``` + +Without `$`, the alias hijacks subpath imports and breaks `@lightsparkdev/origin/styles.css`. + +### Fonts + +Copy fonts from the package into your app's public directory: + +```bash +cp -r node_modules/@lightsparkdev/origin/public/fonts/ public/fonts/ +``` + +The `@font-face` declarations expect fonts served at `/fonts/`. Includes Suisse Intl (Regular 400, Book 450, Medium 500) and Suisse Int'l Mono. + +### Dependencies + +``` +npm install @lightsparkdev/origin sass +``` + +`sass` is required (not optional) — every component imports SCSS modules. + +## Imports + +All components are exported from the package root: + +```tsx +import { Button, Dialog, CentralIcon, Tabs } from '@lightsparkdev/origin'; +``` + +## Component Patterns + +### Compound components (namespace pattern) + +These use dot notation for sub-components: + +```tsx + + Open + + + + Title + Body text + Close + + + +``` + +Compound components: Accordion, AlertDialog, Autocomplete, Breadcrumb, Card, Checkbox, Combobox, Command, ContextMenu, Dialog, Field, Fieldset, InputGroup, Menu, Menubar, Meter, NavigationMenu, Pagination, PhoneInput, Popover, Progress, Radio, Select, Sidebar, Table, Tabs, TextareaGroup, Toast, Tooltip. + +### Simple components (direct props) + +```tsx + +``` + +Simple components: ActionBar, Alert, Avatar, Badge, Button, ButtonGroup, Chip, ChipFilter, Form, Input, Item, Loader, Logo, Separator, Shortcut, Switch, Textarea, Toggle, ToggleGroup, VisuallyHidden. + +### Chart (namespace export) + +```tsx +import { Chart } from '@lightsparkdev/origin'; + + + + + + + + + + +``` + +## Button API + +```tsx +interface ButtonProps { + variant?: 'filled' | 'secondary' | 'outline' | 'ghost' | 'critical' | 'link'; + size?: 'default' | 'compact' | 'dense'; + loading?: boolean; + loadingIndicator?: ReactNode; + leadingIcon?: ReactNode; + trailingIcon?: ReactNode; + iconOnly?: boolean; +} +``` + +## Icons + +Use `CentralIcon` with a `name` prop. All icon names are typed via `CentralIconName`. + +```tsx +import { CentralIcon } from '@lightsparkdev/origin'; + + + +``` + +Props: `name` (required), `size` (default 24), `color` (default "currentColor"), `className`, `style`. + +Strokes scale proportionally with size (1.5px stroke at 24px becomes ~1px at 16px). + +Common icon names by category: + +- Arrows: IconArrow, IconArrowRight, IconArrowLeft, IconArrowUp, IconArrowDown, IconArrowBoxRight, IconArrowOutOfBox, IconRedirectArrow +- Chevrons: IconChevronRight, IconChevronLeft, IconChevronBottom, IconChevronTop, IconChevronDownSmall, IconChevronRightSmall +- Actions: IconPlusSmall, IconPlusLarge, IconMinusSmall, IconCrossSmall, IconCrossLarge, IconCheckmark2, IconPencil, IconTrashCanSimple, IconMagnifyingGlass2, IconFilter2 +- Status: IconCircleCheck, IconCircleCheckFilled, IconCircleInfo, IconCircleInfoFilled, IconExclamationTriangle, IconExclamationTriangleFilled, IconCircleX +- UI: IconSettingsGear1, IconBell, IconEyeOpen, IconEyeSlash, IconLock, IconHome, IconGlobe2, IconLoader +- People: IconPeople2, IconUserDuo, IconUserGroup, IconUserAdded, IconPeopleCircle +- Brands: IconGithub, IconSlack, IconLinear, IconNotion, IconApple, IconClaudeai + +## Design Tokens + +Tokens are CSS custom properties defined in `_variables.scss`. Use them via `var()`: + +- Spacing: `--spacing-xs` (8px), `--spacing-sm` (12px), `--spacing-md` (16px), `--spacing-lg` (20px), `--spacing-xl` (24px), `--spacing-2xl` (32px) +- Corner radius: `--corner-radius-xs`, `--corner-radius-sm` (6px), `--corner-radius-md`, `--corner-radius-lg`, `--corner-radius-full` +- Colors: `--surface-primary`, `--surface-secondary`, `--surface-hover`, `--text-primary`, `--text-secondary`, `--text-tertiary`, `--border-primary`, `--border-secondary`, `--border-critical` +- Typography: `--font-size-base` (14px), `--font-family-sans`, `--font-weight-book`, `--font-weight-medium`, `--font-weight-bold` + +## SCSS Mixins + +Available via `@use 'pkg:@lightsparkdev/origin/tokens/mixins' as *;`: + +- `@include smooth-corners($radius)` — border-radius with future squircle support +- `@include surface-with-hover($base)` — background with hover overlay +- `@include input-focus` — standard input focus state (border + shadow) +- `@include input-critical` — error state (red border + pink shadow) +- `@include button-reset` — strip default button styles +- `@include visually-hidden` — accessible screen-reader-only content +- `@include text-label` — standard label text style + +## Key Conventions + +- All components use `'use client'` — they are client components +- Components are built on `@base-ui/react` primitives +- Styles use CSS Modules (`.module.scss` files) with SCSS +- Components accept standard HTML attributes via prop spreading +- Compound components use React context internally — sub-components must be nested under their Root diff --git a/packages/origin/src/app/globals.scss b/packages/origin/src/app/globals.scss new file mode 100644 index 000000000..4ba2f2ac0 --- /dev/null +++ b/packages/origin/src/app/globals.scss @@ -0,0 +1 @@ +@use "../styles/public"; diff --git a/packages/origin/src/app/layout.tsx b/packages/origin/src/app/layout.tsx new file mode 100644 index 000000000..3dac973cb --- /dev/null +++ b/packages/origin/src/app/layout.tsx @@ -0,0 +1,20 @@ +import type { Metadata } from "next"; +import "@/tokens/_variables.scss"; +import "./globals.scss"; + +export const metadata: Metadata = { + title: "Origin Design System v2", + description: "Base UI + Figma-first component library", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/packages/origin/src/app/page.tsx b/packages/origin/src/app/page.tsx new file mode 100644 index 000000000..35d11a887 --- /dev/null +++ b/packages/origin/src/app/page.tsx @@ -0,0 +1,7170 @@ +"use client"; + +import * as React from "react"; +import { matchSorter } from "match-sorter"; +import { Accordion } from "@/components/Accordion"; +import { Collapsible } from "@/components/Collapsible"; +import { + ActionBar, + ActionBarLabel, + ActionBarActions, +} from "@/components/ActionBar"; +import { Autocomplete } from "@/components/Autocomplete"; +import { Alert } from "@/components/Alert"; +import { AlertDialog } from "@/components/AlertDialog"; +import { Dialog } from "@/components/Dialog"; +import { Drawer } from "@/components/Drawer"; +import { Badge } from "@/components/Badge"; +import { Breadcrumb } from "@/components/Breadcrumb"; +import { Button } from "@/components/Button"; +import { ButtonGroup } from "@/components/ButtonGroup"; +import { InputGroup } from "@/components/InputGroup"; +import { Card } from "@/components/Card"; +import { Checkbox } from "@/components/Checkbox"; +import { Chip, ChipFilter } from "@/components/Chip"; +import { Combobox } from "@/components/Combobox"; +import { Field } from "@/components/Field"; +import { Fieldset } from "@/components/Fieldset"; +import { Form } from "@/components/Form"; +import { CentralIcon } from "@/components/Icon"; +import { Input } from "@/components/Input"; +import { Item } from "@/components/Item"; +import { Loader } from "@/components/Loader"; +import { Command } from "@/components/Command"; +import { Menu } from "@/components/Menu"; +import { Menubar } from "@/components/Menubar"; +import { NavigationMenu } from "@/components/NavigationMenu"; +import { ContextMenu } from "@/components/ContextMenu"; +import { Meter } from "@/components/Meter"; +import { Pagination } from "@/components/Pagination"; +import { PhoneInput } from "@/components/PhoneInput"; +import { Progress } from "@/components/Progress"; +import { Radio } from "@/components/Radio"; +import { Select } from "@/components/Select"; +import { SegmentedNav } from "@/components/SegmentedNav"; +import { Separator } from "@/components/Separator"; +import { Sidebar } from "@/components/Sidebar"; +import { Skeleton } from "@/components/Skeleton"; +import { Shortcut } from "@/components/Shortcut"; +import { Switch } from "@/components/Switch"; +import { Textarea } from "@/components/Textarea"; +import { TextareaGroup } from "@/components/TextareaGroup"; +import { Tabs } from "@/components/Tabs"; +import { Table } from "@/components/Table"; +import * as Chart from "@/components/Chart"; +import { + useReactTable, + getCoreRowModel, + getSortedRowModel, + flexRender, + createColumnHelper, + SortingState, + RowSelectionState, +} from "@tanstack/react-table"; +import { Toast, ToastVariant } from "@/components/Toast"; +import { Tooltip } from "@/components/Tooltip"; +import { Popover } from "@/components/Popover"; +import { PreviewCard } from "@/components/PreviewCard"; +import { Logo } from "@/components/Logo"; +import { Toggle, ToggleGroup } from "@/components/Toggle"; +import * as DatePicker from "@/components/DatePicker"; +import type { DateRange } from "@/components/DatePicker"; +// Data for combobox examples +const fruits = [ + "Apple", + "Banana", + "Cherry", + "Date", + "Elderberry", + "Fig", + "Grape", +]; + +// Toast demo components +function ToastDemo() { + const toastManager = Toast.useToastManager(); + + const showToast = ( + variant: ToastVariant, + title: string, + description?: string, + actionLabel?: string, + ) => { + toastManager.add({ + title, + description, + data: { variant, actionLabel }, + }); + }; + + return ( +
+ + + + + + +
+ ); +} + +function ToastRenderer() { + const toastManager = Toast.useToastManager(); + + return ( + <> + {toastManager.toasts.map((toast) => { + const variant = (toast.data?.variant as ToastVariant) || "default"; + const actionLabel = toast.data?.actionLabel as string | undefined; + return ( + + {variant !== "default" && } + + {toast.title} + {toast.description && ( + {toast.description} + )} + + {actionLabel && {actionLabel}} + + + ); + })} + + ); +} + +// Data for autocomplete examples +const autocompleteFruits = [ + { value: "apple", label: "Apple" }, + { value: "banana", label: "Banana" }, + { value: "cherry", label: "Cherry" }, + { value: "date", label: "Date" }, + { value: "elderberry", label: "Elderberry" }, + { value: "fig", label: "Fig" }, + { value: "grape", label: "Grape" }, +]; + +interface AutocompleteFruit { + value: string; + label: string; +} + +interface FuzzyItem { + label: string; +} + +const fuzzyItems: FuzzyItem[] = [ + { label: "React" }, + { label: "JavaScript" }, + { label: "TypeScript" }, + { label: "Node.js" }, + { label: "CSS Grid" }, + { label: "Flexbox" }, + { label: "Redux" }, + { label: "GraphQL" }, +]; + +function fuzzyFilter(item: FuzzyItem, query: string): boolean { + if (!query) return true; + const results = matchSorter([item], query, { + keys: ["label"], + }); + return results.length > 0; +} + +function highlightMatch(text: string, query: string): React.ReactNode { + if (!query.trim()) { + return text; + } + + const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(`(${escaped})`, "gi"); + const parts = text.split(regex); + const lowerQuery = query.toLowerCase(); + + return ( + + {parts.map((part, i) => + part.toLowerCase() === lowerQuery ? ( + + {part} + + ) : ( + + {part} + + ), + )} + + ); +} + +function FuzzyMatchingDemo() { + const [value, setValue] = React.useState(""); + + return ( +
+ + Fuzzy Matching (try "rct") + + item.label} + value={value} + onValueChange={setValue} + > + + + + + No results found. + + {(item: FuzzyItem) => ( + + {highlightMatch(item.label, value)} + + )} + + + + + +
+ ); +} + +function CommandDemo() { + const [basicOpen, setBasicOpen] = React.useState(false); + const [fullOpen, setFullOpen] = React.useState(false); + + // Basic items (flat) + const basicItems: import("@/components/Command").CommandItem[] = [ + { + id: "1", + label: "Calendar", + icon: , + }, + { + id: "2", + label: "Search Emoji", + icon: , + }, + { + id: "3", + label: "Calculator", + icon: , + }, + { + id: "4", + label: "Settings", + icon: , + }, + ]; + + // Full items (grouped) - 20+ items to test scrolling + const fullItems: import("@/components/Command").CommandGroup[] = [ + { + label: "Suggestions", + items: [ + { + id: "1", + label: "Linear", + icon: , + shortcut: , + }, + { + id: "2", + label: "Figma", + icon: , + shortcut: , + }, + { + id: "3", + label: "Slack", + icon: , + shortcut: , + }, + { + id: "4", + label: "Notion", + icon: , + shortcut: , + }, + { + id: "5", + label: "GitHub", + icon: , + shortcut: , + }, + ], + }, + { + label: "Commands", + items: [ + { + id: "6", + label: "Clipboard History", + icon: , + shortcut: , + keywords: ["clipboard", "paste"], + }, + { + id: "7", + label: "System Preferences", + icon: , + shortcut: , + keywords: ["settings"], + }, + { + id: "8", + label: "Screenshot", + icon: , + shortcut: , + keywords: ["capture", "screen"], + }, + { + id: "9", + label: "Lock Screen", + icon: , + shortcut: , + }, + { + id: "10", + label: "Force Quit", + icon: , + shortcut: , + }, + ], + }, + { + label: "Navigation", + items: [ + { + id: "11", + label: "Go to Dashboard", + icon: , + }, + { + id: "12", + label: "Go to Settings", + icon: , + }, + { + id: "13", + label: "Go to Profile", + icon: , + }, + { + id: "14", + label: "Go to Notifications", + icon: , + }, + { + id: "15", + label: "Go to Help", + icon: , + }, + ], + }, + { + label: "Actions", + items: [ + { + id: "16", + label: "New Document", + icon: , + shortcut: , + }, + { + id: "17", + label: "New Folder", + icon: , + shortcut: , + }, + { + id: "18", + label: "Duplicate", + icon: , + shortcut: , + }, + { + id: "19", + label: "Delete", + icon: , + shortcut: , + }, + { + id: "20", + label: "Archive", + icon: , + shortcut: , + }, + ], + }, + ]; + + // Keyboard shortcut to open (Cmd+K) + React.useEffect(() => { + const down = (e: KeyboardEvent) => { + if (e.key === "k" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + setFullOpen((open) => !open); + } + }; + document.addEventListener("keydown", down); + return () => document.removeEventListener("keydown", down); + }, []); + + return ( +
+ {/* Basic Command */} +
+ + Basic + + + +
+ + {/* Full Command with shortcuts and footer */} +
+ + With Groups, Shortcuts & Footer (⌘K to open) + + + + +
+ + Navigate +
+
+
+ Select + +
+
+ Close + +
+
+
+
+
+
+ ); +} + +function AutocompleteExamples() { + return ( +
+ {/* Basic */} +
+ + Basic + + + + + + + No results found. + + {(item: AutocompleteFruit) => ( + + {item.label} + + )} + + + + + +
+ + {/* With Leading Icons */} +
+ + With Leading Icons + + + + + + + No results found. + + {(item: AutocompleteFruit) => ( + } + > + {item.label} + + )} + + + + + +
+ + {/* Disabled */} +
+ + Disabled + + + + + + + + {(item: AutocompleteFruit) => ( + + {item.label} + + )} + + + + + +
+ + {/* Fuzzy Matching */} + +
+ ); +} + +function MenuExamples() { + const [showGrid, setShowGrid] = React.useState(true); + const [showRulers, setShowRulers] = React.useState(false); + const [sortBy, setSortBy] = React.useState("name"); + + return ( +
+ {/* Basic */} +
+ + Basic + + + }> + Open Menu + + + + + New File + Open File + Save + + Export + + + + +
+ + {/* With Icons */} +
+ + With Icons + + + }> + Edit + + + + + + + Edit + + + + Copy + + + + Delete + + + + + +
+ + {/* Checkbox Items */} +
+ + Checkbox Items + + + }> + View Options + + + + + + + + + Show Grid + + + + + + Show Rulers + + + + + +
+ + {/* Radio Items */} +
+ + Radio Items + + + }> + Sort By + + + + + + + + + + Name + + + + + + Date + + + + + + Size + + + + + + +
+ + {/* With Groups */} +
+ + With Groups + + + }> + Preferences + + + + + + Account + Profile + Settings + + + + Help + Documentation + Support + + + + + +
+ + {/* With Submenu */} +
+ + With Submenu + + + }> + File + + + + + New + Open + + + Share + + + + + + Email + Messages + AirDrop + + + + + + Close + + + + +
+
+ ); +} + +function MenubarDemo() { + return ( +
+
+ + Basic + + + + File + + + + New + Open + Save + + Export + + + + + + + Edit + + + + Undo + Redo + + Cut + Copy + Paste + + + + + + + View + + + + Zoom In + Zoom Out + + Full Screen + + + + + + + Help + + + + Documentation + About + + + + + +
+ +
+ + Disabled + + + + File + + + + New + + + + + + + Edit + + + + Cut + + + + + +
+
+ ); +} + +function ContextMenuExamples() { + const [showGrid, setShowGrid] = React.useState(true); + const [sortBy, setSortBy] = React.useState("name"); + + const TriggerArea = ({ children }: { children?: React.ReactNode }) => ( +
+ {children || "Right-click here"} +
+ ); + + return ( +
+
+ + Basic + + + + + + + + + Cut + Copy + Paste + + Delete + + + + +
+ +
+ + With Checkbox Items + + + + Right-click for view options + + + + + + + Show Grid + + + + + +
+ +
+ + With Radio Items + + + + Right-click to sort + + + + + + Sort by + + + + Name + + + + Date + + + + Size + + + + + + + +
+ +
+ + With Submenu + + + + + + + + + New + Open + + + Share + + + + + + Email + Messages + Copy Link + + + + + + Delete + + + + +
+
+ ); +} + +function PaginationDemo() { + const [page, setPage] = React.useState(1); + const [pageSize, setPageSize] = React.useState(100); + const totalItems = 2500; + + return ( +
+
+ + Default + + + + setPageSize(Number(v))} + > + + + + + + + + + + 10 + + + 25 + + + 50 + + + 100 + + + + + + + + + + + + +
+ +
+ + First Page (Previous disabled) + + + + + + + + + +
+ +
+ + Last Page (Next disabled) + + + + + + + + + +
+ +
+ + Single Page (both disabled) + + + + + + + + + +
+
+ ); +} + +// Phone Input demo data +const phoneCountries = [ + { code: "US", name: "United States", dialCode: "+1" }, + { code: "GB", name: "United Kingdom", dialCode: "+44" }, + { code: "DE", name: "Germany", dialCode: "+49" }, + { code: "FR", name: "France", dialCode: "+33" }, + { code: "JP", name: "Japan", dialCode: "+81" }, + { code: "AU", name: "Australia", dialCode: "+61" }, + { code: "CA", name: "Canada", dialCode: "+1" }, + { code: "IN", name: "India", dialCode: "+91" }, +]; + +type PhoneCountry = (typeof phoneCountries)[number]; + +// Circle-flags CDN URL helper +function getFlagUrl(code: string) { + return `https://hatscripts.github.io/circle-flags/flags/${code.toLowerCase()}.svg`; +} + +function PhoneInputDemo() { + const [country, setCountry] = React.useState(phoneCountries[0]); + const [phone, setPhone] = React.useState(""); + const [invalidCountry, setInvalidCountry] = React.useState( + phoneCountries[0], + ); + const [invalidPhone, setInvalidPhone] = React.useState(""); + + return ( +
+
+ + Default + + + v && setCountry(v)} + > + + + {(c: PhoneCountry) => ( + <> + + + + {c.dialCode} + + )} + + + + + {phoneCountries.map((c) => ( + + + + + + {c.name} ({c.dialCode}) + + + + ))} + + + setPhone(e.target.value)} + placeholder="Enter phone" + /> + +
+ +
+ + Invalid + + + v && setInvalidCountry(v)} + > + + + {(c: PhoneCountry) => ( + <> + + + + {c.dialCode} + + )} + + + + + {phoneCountries.map((c) => ( + + + + + + {c.name} ({c.dialCode}) + + + + ))} + + + setInvalidPhone(e.target.value)} + placeholder="Enter phone" + /> + +
+ +
+ + Disabled + + + + + + {(c: PhoneCountry) => ( + <> + + + + {c.dialCode} + + )} + + + + + {phoneCountries.map((c) => ( + + + + + + {c.name} ({c.dialCode}) + + + + ))} + + + + +
+
+ ); +} + +function ComboboxExamples() { + // Use the useFilter hook for filtering support + const filter = Combobox.useFilter(); + + return ( +
+ {/* Single Select with filtering */} +
+ + Single Select + + + + + + + + + + + + + + {(item: string) => ( + + + {item} + + )} + + + + + +
+ + {/* With Clear Button (shows next to chevron when value exists) */} +
+ + With Clear Button + + + + + + + + + + + + + + + {(item: string) => ( + + + {item} + + )} + + + + + +
+ + {/* With Trailing Icons */} +
+ + With Trailing Icons + + + + + + + + + + + + + + {(item: string) => ( + } + > + + {item} + + )} + + + + + +
+ + {/* With Leading Icons (indicator on right) */} +
+ + With Leading Icons + + + + + + + + + + + + + + {(item: string) => ( + } + > + {item} + + + )} + + + + + +
+ + {/* Multi Select - no chevron per Figma spec and Base UI pattern */} +
+ + Multi Select + + + + + + {(values: string[]) => ( + <> + {values?.map((value) => ( + + {value} + + + ))} + {/* Input is INSIDE Value - clicking anywhere opens popup */} + 0 ? "" : "Select fruits..."} + /> + + )} + + + {/* No ActionButtons/Trigger for multi-select - per Figma spec */} + + + + + + + {(item: string) => ( + + + {item} + + )} + + + + + +
+ + {/* Disabled */} +
+ + Disabled + + + + + + + + + + + + + {(item: string) => ( + + + {item} + + )} + + + + + +
+
+ ); +} + +// Table example data +interface TablePerson { + id: string; + name: string; + email: string; + role: string; + status: "active" | "inactive"; +} + +const tableData: TablePerson[] = [ + { + id: "1", + name: "Alice Johnson", + email: "alice@example.com", + role: "Engineer", + status: "active", + }, + { + id: "2", + name: "Bob Smith", + email: "bob@example.com", + role: "Designer", + status: "active", + }, + { + id: "3", + name: "Carol White", + email: "carol@example.com", + role: "Manager", + status: "inactive", + }, + { + id: "4", + name: "David Brown", + email: "david@example.com", + role: "Engineer", + status: "active", + }, + { + id: "5", + name: "Eve Davis", + email: "eve@example.com", + role: "Designer", + status: "active", + }, +]; + +const tableColumnHelper = createColumnHelper(); + +function TableExamples() { + const [sorting, setSorting] = React.useState([]); + const [rowSelection, setRowSelection] = React.useState({}); + + const columns = [ + tableColumnHelper.display({ + id: "select", + header: ({ table }) => ( + + + + ), + cell: ({ row }) => ( + + + + ), + meta: { variant: "checkbox" as const }, + }), + tableColumnHelper.accessor("name", { + header: "Name", + cell: (info) => ( + + ), + enableSorting: true, + }), + tableColumnHelper.accessor("role", { + header: "Role", + cell: (info) => info.getValue(), + enableSorting: true, + }), + tableColumnHelper.accessor("status", { + header: "Status", + cell: (info) => ( + + {info.getValue()} + + ), + enableSorting: false, + meta: { align: "right" as const }, + }), + ]; + + const table = useReactTable({ + data: tableData, + columns, + state: { sorting, rowSelection }, + onSortingChange: setSorting, + onRowSelectionChange: setRowSelection, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getRowId: (row) => row.id, + enableRowSelection: true, + }); + + const hasSelection = Object.keys(rowSelection).length > 0; + + return ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {table.getRowModel().rows.map((row, index) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ))} + + +
+ ); +} + +function LiveDemo() { + const [data, setData] = React.useState<{ time: number; value: number }[]>([]); + const [value, setValue] = React.useState(100); + const valueRef = React.useRef(100); + + React.useEffect(() => { + const now = Date.now() / 1000; + const seed: { time: number; value: number }[] = []; + let v = 100; + for (let i = 30; i >= 0; i--) { + v += (Math.random() - 0.5) * 4; + seed.push({ time: now - i, value: v }); + } + valueRef.current = v; + setData(seed); + setValue(v); + + const interval = setInterval(() => { + const t = Date.now() / 1000; + valueRef.current += (Math.random() - 0.5) * 3; + const next = valueRef.current; + setValue(next); + setData((prev) => { + const cutoff = t - 60; + const filtered = prev.filter((p) => p.time > cutoff); + return [...filtered, { time: t, value: next }]; + }); + }, 200); + + return () => clearInterval(interval); + }, []); + + return ( + v.toFixed(1)} + /> + ); +} + +const drawerRequests = [ + { + id: "ck8qs-177", + method: "GET", + path: "/customers", + status: 200, + duration: "314ms", + host: "api.example.com", + cache: "HIT", + }, + { + id: "ck8qs-178", + method: "POST", + path: "/transactions", + status: 201, + duration: "892ms", + host: "api.example.com", + cache: "MISS", + }, + { + id: "ck8qs-179", + method: "GET", + path: "/fees", + status: 200, + duration: "156ms", + host: "api.example.com", + cache: "HIT", + }, +]; + +function DrawerDemo() { + const [selected, setSelected] = React.useState< + (typeof drawerRequests)[0] | null + >(null); + + return ( +
+ + + + + + + + + + + {drawerRequests.map((req) => ( + setSelected(req)} + style={{ + borderBottom: "var(--stroke-xs) solid var(--border-primary)", + cursor: "pointer", + }} + onMouseOver={(e) => { + e.currentTarget.style.backgroundColor = "var(--surface-hover)"; + }} + onMouseOut={(e) => { + e.currentTarget.style.backgroundColor = ""; + }} + > + + + + + + ))} + +
+ Method + + Path + + Status + + Duration +
+ {req.method} + + {req.path} + + + {req.status} + + + {req.duration} +
+ + { + if (!open) setSelected(null); + }} + swipeDirection="right" + > + + + + + {selected && ( + <> +
+ + {selected.method} {selected.path} + +
+ + {selected.status} + + + } + > + + +
+
+ +
+
+ {[ + ["Request ID", selected.id], + ["Path", selected.path], + ["Host", selected.host], + ["Duration", selected.duration], + ["Cache", selected.cache], + ].map(([label, value]) => ( +
+ + {label} + + + {value} + +
+ ))} +
+
+
+ + )} +
+
+
+
+
+ ); +} + +function DatePickerDemo() { + const [singleDate, setSingleDate] = React.useState(null); + const [rangeValue, setRangeValue] = React.useState( + null, + ); + const [mode, setMode] = React.useState<"single" | "range">("range"); + const [includeTime, setIncludeTime] = React.useState(false); + + return ( +
+
+

+ Single date +

+ setSingleDate(v as Date)} + > + + + + + + + +
+
+

+ Date range +

+ + + + + + + { + setMode(v ? "range" : "single"); + setRangeValue(null); + }} + /> + + + + + + + + + +
+
+

+ French (locale) +

+ + + + + + + { + setMode(v ? "range" : "single"); + setRangeValue(null); + }} + /> + + + + + + + + + +
+
+ ); +} + +function SegmentedNavDemo({ + ariaLabel, + items, + initialActive, +}: { + ariaLabel: string; + items: string[]; + initialActive: string; +}) { + const [activeItem, setActiveItem] = React.useState(initialActive); + + return ( + + {items.map((item) => ( + { + event.preventDefault(); + setActiveItem(item); + }} + /> + } + > + {item} + + ))} + + ); +} + +function GroupedSegmentedNavDemo({ + ariaLabel, + groups, + initialActive, +}: { + ariaLabel: string; + groups: string[][]; + initialActive: string; +}) { + const [activeItem, setActiveItem] = React.useState(initialActive); + + return ( + + {groups.map((group, index) => ( + + {group.map((item) => ( + { + event.preventDefault(); + setActiveItem(item); + }} + /> + } + > + {item} + + ))} + + ))} + + ); +} + +export default function Home() { + return ( +
+ +

Origin

+

+ Design system rebuild — Base UI + Figma-first approach. +

+ +

Accordion Component

+ + + + + What is Origin? + + + Origin is a design system that combines Base UI for accessibility + and behavior with Figma Dev Mode CSS for pixel-perfect styling. + + + + + + How does it work? + + + Components are designed in Figma using tokenized properties. The + Figma lint plugin validates structure against Base UI anatomy. CSS + is extracted from Dev Mode and transformed to use semantic tokens. + + + + + + Why this approach? + + + This approach ensures perfect design-to-code fidelity while + maintaining full accessibility through Base UI primitives. + + + +

Action Bar Component

+ +
+ + 4 transactions selected + + + + + + + + 3 users selected + + + + + +
+

Alert Component

+ +
+ + + + +
+

Alert Dialog Component

+ +
+ + }> + Open Alert Dialog + + + + + Delete Item? + + This action cannot be undone. The item will be permanently + removed from your account. + + + }> + Cancel + + }> + Delete + + + + + + + + }> + Destructive Action + + + + + Are you sure? + + This will permanently delete your account and all associated + data. + + + }> + Cancel + + }> + Delete Account + + + + + +
+

Autocomplete Component

+ + +
+ +

Badge Component

+ +
+
+ Subtle: + Label + Label + Label + Label + Label + Label + Label + Label +
+
+ Vibrant: + + Label + + + Label + + + Label + + + Label + + + Label + + + Label + + + Label + + + Label + +
+
+

Breadcrumb Component

+ +
+
+ + Default + + + + + Home + + + Products + + + Shoes + + + +
+ +
+ + With Collapsed Items + + + + + Home + + + + + + + Running + + + + Trail Runners + + + +
+ +
+ + Custom Separator + + + + + Home + + + Products + + + Shoes + + + +
+
+

Button Component

+ + {/* Variants */} +
+ + + + + + +
+ + {/* Sizes */} +
+ + + +
+ + {/* With Icons */} +
+ + + +
+ + {/* Icon Only */} +
+
+ + {/* States */} +
+ + + +
+ + {/* Link Variant */} +
+ Link: + + + +
+

Button Group

+ +
+
+ + Filled horizontal + + + + + + +
+
+ + Outline horizontal + + + + + + +
+
+ + Secondary horizontal + + + + + + +
+
+ +
+
+ + Filled vertical + + + + + + +
+
+ + Outline vertical + + + + + + +
+
+ + Secondary vertical + + + + + + +
+
+
+ +

Card Component

+ +
+ + + + Structured + With card surface + + + +

Body content with sectioned layout.

+
+ + + +
+ + + + Simple + No card surface + + +

Body content with uniform padding.

+
+ +
+
+ +

Charts

+ +

Bar

+
+
+

+ Grouped +

+ +
+
+

+ Stacked +

+ +
+
+

+ Horizontal +

+ +
+
+

+ Single series + reference +

+ +
+
+ +

BarList

+
+
+ +
+
+ +

BarList (ranked)

+
+
+

+ With rank, change indicators, and secondary values +

+ `$${v.toLocaleString()}`} + formatSecondaryValue={(v) => `${v}%`} + showRank + /> +
+
+ +

Composed

+
+
+

+ Bar + line, dual Y-axes +

+ `${v}%`} + /> +
+
+ +

Donut

+
+
+ +
+
+ +
+
+ +

Funnel

+
+
+

+ Conversion pipeline +

+ v.toLocaleString()} + /> +
+
+ +

Gauge

+
+
+

+ Default +

+ `${v.toFixed(2)}s`} + /> +
+
+

+ Minimal +

+ `${v.toFixed(2)}s`} + /> +
+
+ +

Line

+
+
+

+ Multi-series with grid +

+ +
+
+

+ Area fill + fadeLeft +

+ +
+
+

+ Dashed + dotted series +

+ +
+
+

+ Reference lines +

+ +
+
+ +

Live (Real-Time)

+
+
+

+ Streaming data (random walk) +

+ +
+
+ +

Sankey

+
+
+

+ Budget allocation +

+ `$${v}k`} + /> +
+
+ +

Scatter

+
+
+

+ Multi-series with grid +

+ `${v}%`} + formatYLabel={(v) => `$${v}`} + /> +
+
+ +

Sparkline

+
+
+

+ Line +

+ +
+
+

+ Line +

+ +
+
+

+ Bar +

+ +
+
+ +

Split (Distribution)

+
+
+

+ Shade ramp +

+ `$${v.toLocaleString()}`} + showValues + /> +
+
+ +

Stacked Area

+
+
+ +
+
+ +

Tooltip Modes

+
+
+

+ simple +

+ +
+
+

+ compact +

+ +
+
+

+ detailed +

+ +
+
+ +

Uptime

+
+
+ ({ + status: (i === 12 + ? "down" + : i === 34 + ? "degraded" + : i === 67 + ? "down" + : i === 45 + ? "degraded" + : "up") as "up" | "down" | "degraded", + label: `Day ${i + 1}`, + }))} + label="90 days — 97.8% uptime" + /> +
+
+ +

Waterfall

+
+
+

+ Revenue breakdown +

+ `$${v}`} + /> +
+
+

Checkbox Component

+ +
+ {/* Default variant */} + + Legend + + + + + Help text goes here. + + + {/* Card variant */} + + Legend + + + + + Help text goes here. + + + {/* Critical state */} + + Legend + + + + + Error text goes here. + +
+

Chip Component

+ +
+
+ Default MD + console.log("dismissed")}>label +
+
+ Default SM + console.log("dismissed")}> + label + +
+
+ Filter MD + console.log("dismissed")} + /> +
+
+ Filter SM + console.log("dismissed")} + /> +
+
+ Disabled + console.log("dismissed")}> + label + +
+
+ No dismiss + label +
+
+

Collapsible

+ +
+ + Advanced settings + + These settings are for experienced users. Adjust log level, enable + debug mode, and configure custom telemetry endpoints. + + + + + Details + + This panel starts open by default. Useful for content that should be + visible on first load but dismissable. + + + + + Locked section + This content is locked. + +
+

Combobox Component

+ + +
+ +

Command Component

+ + +
+ +

Context Menu Component

+ + +
+ +

DatePicker

+ +
+ +

Dialog Component

+ +
+ + }> + Open Dialog + + + + + + + Dialog Title + + This is a description of the dialog content. + + + +

+ Dialog content goes here. This area can contain forms, text, + or any other content. +

+
+ + }> + Cancel + + + +
+
+
+ + + }> + Without Close Button + + + + + + No Close Button + + This dialog does not have an X close button. + + + +

+ The user must use the footer buttons or press Escape to close. +

+
+ + }> + Cancel + + }> + Done + + +
+
+
+
+

Drawer

+ + +
+ +

Field Component

+ +
+ + Default + + Help text goes here. + + + + Filled + + Help text goes here. + + + + Disabled + + Help text goes here. + + + + Invalid + + Error text goes here. + +
+

Fieldset Component

+ +
+
+ + Vertical (default) + + First Name + + Your legal first name. + + + Last Name + + Your legal last name. + + +
+
+ + Horizontal + + City + + + + State + + + + Zip + + + +
+
+

Form Component

+ +
+
{ + e.preventDefault(); + alert("Form submitted!"); + }} + > + + Email + + We'll never share your email. + + + Password + + + +
+
+

Input Component

+ +
+
+ + Default + + +
+
+ + Filled + + +
+
+ + Disabled + + +
+
+ + Read Only + + +
+
+

Input Group

+ +
+ + + + + + + + + + Search + + + + + Search + + + + + USD + + + + + + USD + + + + + https:// + + + + + + + Copy + + + + + + + + + + + + + + https:// + + + Go + + + + + + + + + + + + + + $ + + USD + + + + + + + + + + + + + + + +
+ +
+

Item Component

+ +
+ } + trailing={} + onClick={() => console.log("clicked")} + /> + } + trailing={} + clickable={false} + /> + } + onClick={() => console.log("clicked")} + /> + +
+

Loader Component

+ +
+ +
+

Logo Component

+ +
+
+ + Lightspark Logo Regular + + +
+
+ + Lightspark Logo Light + + +
+
+ + Lightspark Logomark Regular + + +
+
+ + Lightspark Logomark Light + + +
+
+ + Lightspark Wordmark + + +
+
+ + Grid Logo + + +
+
+ + Grid Logomark + + +
+
+

Menu Component

+ + +
+ +

Menubar Component

+ + +
+ +

Meter Component

+ +
+
+ + Storage (50%) + + + Storage used + + + + + +
+ +
+ + Low (25%) + + + Battery level + + + + + +
+ +
+ + High (90%) + + + Disk space + + + + + +
+ +
+ + Track Only + + + + + + +
+
+

Navigation Menu Component

+ +
+
+ + With Dropdown + + + + + + Products + + + + + + + + Dashboard + + + + Analytics + + + + Reports + + + + + + Resources + + + + + + + Documentation + + + API Reference + + Blog + + + + Pricing + + + + + + + + + + + +
+ +
+ + Links Only + + + + + Home + + + + About + + + + Contact + + + +
+ +
+ + With Group Labels + + + + + + Products + + + + + + + + Analytics + + + + Dashboard + + + + Reports + + + + + + Settings + + + + Preferences + + + + Account + + + + + + + + + + + + + + +
+ +
+ + With Actions + + + + + + Dashboard + + + + Settings + + + + + + + + + + + + + alert("Signed out!")}> + Sign Out + + + + +
+
+

Pagination Component

+ + + +
+

Phone Input Component

+ + + +
+

Popover Component

+ +
+ + }> + Notifications + + + + + + Notifications + + + You are all caught up. Good job! + + + + + + + + }> + Settings + + + + +
+ + Settings + + + + + } + /> +
+ + Adjust your notification preferences and alert thresholds. + +
+
+
+
+ + + }> + Modal Popover + + + + + +
+ + Confirm Action + + + This action requires your confirmation before proceeding. + +
+
+ } + > + Cancel + + } + > + Confirm + +
+
+
+
+
+
+

Preview Card

+ +
+ + + Hover to preview + + + + + +

+ A lightweight preview of the linked content +

+
+
+
+
+ + + + Rich preview + + + + +
+ +
+
+ + Typography Guide + + + Learn about text styles, mixins, and the type scale + +
+
+
+
+
+
+

Progress Component

+ +
+
+ + Default (50%) + + + Export data + + + + + +
+ +
+ + Complete (100%) + + + Upload complete + + + + + +
+ +
+ + Indeterminate + + + Loading... + + + + +
+ +
+ + Track Only + + + + + + +
+
+

Radio Component

+ +
+ {/* Default variant */} + + Legend + + + + + Help text goes here. + + + {/* Card variant */} + + Legend + + + + + Help text goes here. + + + {/* Critical state */} + + Legend + + + + + Error text goes here. + +
+

SegmentedNav Component

+ +
+
+ + Flat links + + +
+ +
+ + Grouped links + + +
+ +
+ + Longer labels + + +
+
+

Select Component

+ +
+
+ + Default + + + + + + + + + + + + + Apple + + + + Banana + + + + Orange + + + + + + +
+ +
+ + With Groups + + + + + + + + + + + + + Apple + + + + Banana + + + + + Vegetables + + + + Carrot + + + + Broccoli + + + + + + + +
+ +
+ + With Trailing Icons + + + + + + + + + + + } + > + + United States + + } + > + + United Kingdom + + } + > + + Germany + + + + + + +
+ +
+ + Disabled + + + + + + + + + + + + + Apple + + + + + + +
+ +
+ + Multi Select + + + + + {(selected: string[]) => { + if (selected.length === 0) { + return Select fruits; + } + const labels: Record = { + apple: "Apple", + banana: "Banana", + orange: "Orange", + }; + const first = labels[selected[0]]; + return selected.length === 1 + ? first + : `${first} +${selected.length - 1}`; + }} + + + + + + + + + + Apple + + + + Banana + + + + Orange + + + + + + +
+ +
+ + Ghost Variant (minimal inline) + + + + + + + + + + + + + Apple + + + + Banana + + + + Orange + + + + + + +
+ +
+ + Hybrid Variant (for navbars/toolbars) + +
+ Environment: + + + + {(value: string) => { + const labels: Record = { + production: "Production", + sandbox: "Sandbox", + staging: "Staging", + }; + return labels[value] || value; + }} + + + + + + + + + Production + + + + Sandbox + + + + Staging + + + + + + + +
+
+ +
+ + Hybrid Disabled + + + + + {(value: string) => { + const labels: Record = { + production: "Production", + sandbox: "Sandbox", + staging: "Staging", + }; + return labels[value] || value; + }} + + + + + + + + + Production + + + + + + + +
+ +
+ + Empty State + + + + + + + + + + No options available + + + + +
+
+

Separator Component

+ +
+
+ + Default (1px) + + +
+ +
+ + Hairline (0.5px) + + +
+ +
+ + Vertical in Navigation + + +
+ +
+ + Vertical Hairline + +
+ Left + + Right +
+
+
+

Shortcut Component

+ +
+
+ Single Key + +
+
+ Two Keys + +
+
+ Three Keys + +
+
+ Common +
+ + Copy + + + Paste + + + Undo + +
+
+
+

Sidebar Component

+ +
+
+ + + +
+ +
+
+ + + Default Items + + } + active + > + Dashboard + + } + trailing={} + > + Profile + + } + disabled + > + Disabled + + + + + + + + + Submenu (Vertical Chevron) + + + + } + label="Projects" + defaultOpen + > + } + > + Alpha + + } + active + > + Beta + + + + + + + + + + Tree (Horizontal Chevron) + + + + } + label="Files" + defaultOpen + > + } + > + Document + + + } + label="Nested" + > + } + > + Child + + + + + + + + + + Drilldown (Navigate) + + + } + > + Teams + + } + > + Members + + + + + + + } + > + Settings + + + +
+
+
+
+

Skeleton Component

+ +
+
+

+ Standalone +

+ +
+ +
+

+ Text lines (grouped) +

+ +
+ + + +
+
+
+ +
+

+ Avatar + name (grouped) +

+ +
+ +
+ + +
+
+
+
+ +
+

+ Card (grouped) +

+ +
+ + + +
+
+
+ +
+

+ Table rows (grouped) +

+ +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ + + +
+ ))} +
+
+
+ +
+

+ Form (grouped) +

+ +
+
+ + +
+
+ + +
+ +
+
+
+ +
+

+ On surface-secondary +

+
+ +
+ +
+ + +
+
+
+
+
+ +
+

+ On surface-tertiary +

+
+ +
+ +
+ + +
+
+
+
+
+ +
+

+ On dark surface +

+
+ +
+ +
+ + +
+
+
+
+
+
+ +

Switch Component

+ +
+
+ SM Off + +
+
+ SM On + +
+
+ MD Off + +
+
+ MD On + +
+
+ Disabled Off + +
+
+ Disabled On + +
+
+ Read Only + +
+
+

Table Component

+ + +
+ +

Tabs Component

+ +
+
+ + Default Variant + + + + Account + Password + Settings + + + Manage your account settings and preferences. + + + Change your password and security options. + + + Configure application settings. + + +
+ +
+ + Minimal Variant + + + + Overview + Details + History + + + Overview content without container background. + + Details content. + History content. + +
+ +
+ + With Disabled Tab + + + + Active + + Disabled + + Another + + This tab is active. + + This panel cannot be accessed. + + Another tab content. + +
+
+

Textarea

+ +
+
+ + Default + +