Initial commit

This commit is contained in:
2026-03-10 21:30:52 -03:00
commit 72a4f0be26
145 changed files with 14881 additions and 0 deletions

View File

@@ -0,0 +1,68 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import { fixupPluginRules } from '@eslint/compat';
import reactPlugin from 'eslint-plugin-react';
import reactHooksPlugin from 'eslint-plugin-react-hooks';
import jsxA11yPlugin from 'eslint-plugin-jsx-a11y';
/** Base ESLint config for all TS packages (no React). */
export const base = tseslint.config(
js.configs.recommended,
...tseslint.configs.recommended,
{
rules: {
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/consistent-type-imports': [
'error',
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' },
],
'no-console': 'warn',
'no-restricted-imports': [
'error',
{
patterns: [
{
regex: '^(\\.+/[^\'"]*)\\.(js|jsx|ts|tsx)$',
message:
'Use extensionless imports. Import from "./module" instead of "./module.js".',
},
],
},
],
},
},
{
ignores: ['**/dist/**', '**/generated/**', '**/.next/**', '**/node_modules/**'],
},
);
/**
* ESLint config for React/Next.js packages.
*
* `fixupPluginRules` wraps ESLint 9-era plugins for ESLint 10 compatibility.
*/
export const react = tseslint.config(...base, {
plugins: {
react: fixupPluginRules(reactPlugin),
'react-hooks': fixupPluginRules(reactHooksPlugin),
'jsx-a11y': fixupPluginRules(jsxA11yPlugin),
},
settings: {
react: { version: 'detect' },
},
rules: {
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off',
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
'jsx-a11y/alt-text': 'error',
'jsx-a11y/aria-props': 'error',
'jsx-a11y/aria-role': 'error',
'jsx-a11y/interactive-supports-focus': 'warn',
'jsx-a11y/label-has-associated-control': 'error',
},
});
export default base;

View File

@@ -0,0 +1,40 @@
{
"name": "@dwellops/config",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
"./eslint": "./eslint/index.js",
"./prettier": "./prettier/index.js",
"./stylelint": "./stylelint/index.js",
"./tsconfig/base.json": "./tsconfig/base.json",
"./tsconfig/nextjs.json": "./tsconfig/nextjs.json",
"./tsconfig/node.json": "./tsconfig/node.json",
"./tsconfig/react-library.json": "./tsconfig/react-library.json",
"./vitest": "./vitest/index.js"
},
"dependencies": {
"@eslint/js": "catalog:",
"@eslint/compat": "catalog:",
"typescript-eslint": "catalog:",
"eslint-plugin-react": "catalog:",
"eslint-plugin-react-hooks": "catalog:",
"eslint-plugin-jsx-a11y": "catalog:",
"stylelint-config-standard": "catalog:"
},
"peerDependencies": {
"vitest": ">=4.0.0"
},
"peerDependenciesMeta": {
"vitest": {
"optional": true
}
},
"devDependencies": {
"typescript": "catalog:",
"eslint": "catalog:",
"prettier": "catalog:",
"stylelint": "catalog:",
"@types/node": "catalog:"
}
}

View File

@@ -0,0 +1,19 @@
/** @type {import('prettier').Config} */
const config = {
tabWidth: 4,
singleQuote: true,
trailingComma: 'all',
semi: true,
printWidth: 100,
bracketSpacing: true,
arrowParens: 'always',
endOfLine: 'lf',
overrides: [
{
files: ['*.json', '*.yaml', '*.yml'],
options: { tabWidth: 2 },
},
],
};
export default config;

View File

@@ -0,0 +1,31 @@
/** @type {import('stylelint').Config} */
const config = {
extends: ['stylelint-config-standard'],
rules: {
// CSS custom properties (design tokens) — allowed anywhere
'custom-property-pattern': null,
// CSS Modules compose pattern
'value-keyword-case': ['lower', { camelCaseSvgKeywords: true }],
// Allow CSS nesting (supported by PostCSS preset-env)
'no-descending-specificity': null,
},
overrides: [
{
files: ['**/*.module.css'],
rules: {
// CSS Modules classes are accessed as JS identifiers, so camelCase is idiomatic
'selector-class-pattern': [
'^[a-z][a-zA-Z0-9]*$',
{ message: 'Expected class selector to be camelCase (CSS Modules)' },
],
// :local() and :global() selectors in CSS Modules
'selector-pseudo-class-no-unknown': [
true,
{ ignorePseudoClasses: ['local', 'global'] },
],
},
},
],
};
export default config;

View File

@@ -0,0 +1,20 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
}
}

View File

@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./base.json",
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowJs": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }]
}
}

View File

@@ -0,0 +1,10 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./base.json",
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler"
}
}

View File

@@ -0,0 +1,9 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./base.json",
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "ES2022"],
"jsx": "react-jsx"
}
}

View File

@@ -0,0 +1,37 @@
import { defineConfig } from 'vitest/config';
/**
* Creates a base Vitest config for a workspace package.
*
* @param {import('vitest/config').UserConfig} [overrides] - Package-specific overrides.
* @returns {import('vitest/config').UserConfig}
*/
export function createVitestConfig(overrides = {}) {
return defineConfig({
test: {
globals: true,
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
thresholds: {
lines: 85,
functions: 85,
branches: 85,
statements: 85,
},
exclude: [
'**/node_modules/**',
'**/dist/**',
'**/generated/**',
'**/*.d.ts',
'**/*.config.*',
'**/index.ts',
],
},
},
...overrides,
});
}
export default createVitestConfig();

View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'prisma/config';
/**
* Prisma 7 CLI configuration.
*
* datasource.url is read from DATABASE_URL at CLI runtime.
* Falls back to a placeholder so that `prisma generate` works without a live
* database (no connection is made during code generation).
*/
export default defineConfig({
schema: '../prisma/schema.prisma',
migrations: {
path: '../prisma/migrations',
seed: 'tsx prisma/seed.ts',
},
datasource: {
url: process.env['DATABASE_URL'] ?? 'postgresql://placeholder@localhost:5432/placeholder',
},
});

1
packages/db/.env.example Normal file
View File

@@ -0,0 +1 @@
DATABASE_URL="postgresql://dwellops:dwellops@localhost:5432/dwellops_dev?schema=public"

View File

@@ -0,0 +1,3 @@
import { base } from '@dwellops/config/eslint';
export default base;

41
packages/db/package.json Normal file
View File

@@ -0,0 +1,41 @@
{
"name": "@dwellops/db",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./client": {
"types": "./src/client.ts",
"default": "./src/client.ts"
}
},
"scripts": {
"db:generate": "prisma generate",
"db:migrate": "prisma migrate deploy",
"db:migrate:dev": "prisma migrate dev",
"db:push": "prisma db push",
"db:studio": "prisma studio",
"db:seed": "tsx prisma/seed.ts",
"lint": "eslint src",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@prisma/client": "catalog:",
"@prisma/adapter-pg": "catalog:",
"pg": "catalog:",
"@dwellops/types": "workspace:*"
},
"devDependencies": {
"prisma": "catalog:",
"eslint": "catalog:",
"@types/pg": "catalog:",
"@dwellops/config": "workspace:*",
"typescript": "catalog:",
"tsx": "catalog:",
"@types/node": "catalog:"
}
}

View File

@@ -0,0 +1,154 @@
// Prisma schema for dwellops-platform
// Better Auth requires User, Session, Account, and Verification models
// with specific field names — do not rename them.
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
// ─── Better Auth required models ───────────────────────────────────────────
model User {
id String @id @default(cuid())
email String @unique
name String?
emailVerified Boolean @default(false)
image String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions Session[]
accounts Account[]
memberships Membership[]
@@map("users")
}
model Session {
id String @id
userId String
token String @unique
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ipAddress String?
userAgent String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("sessions")
}
model Account {
id String @id
userId String
accountId String
providerId String
accessToken String?
refreshToken String?
idToken String?
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
password String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("accounts")
}
model Verification {
id String @id
identifier String
value String
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("verifications")
}
// ─── HOA domain models ─────────────────────────────────────────────────────
/// A homeowners association managed by the platform.
/// In self-hosted mode there is typically one HOA per deployment,
/// but the schema supports multiple to enable future SaaS evolution.
model Hoa {
id String @id @default(cuid())
name String
slug String @unique
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
units Unit[]
memberships Membership[]
@@map("hoas")
}
/// A dwelling unit within an HOA (apartment, townhouse, lot, etc.).
model Unit {
id String @id @default(cuid())
hoaId String
identifier String
address String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
hoa Hoa @relation(fields: [hoaId], references: [id], onDelete: Cascade)
memberships Membership[]
@@unique([hoaId, identifier])
@@map("units")
}
/// Supported roles within an HOA.
enum Role {
ADMIN
BOARD_MEMBER
TREASURER
OWNER
TENANT
VIEWER
}
/// Connects a User to an HOA with a role, optionally scoped to a Unit.
model Membership {
id String @id @default(cuid())
userId String
hoaId String
unitId String?
role Role @default(OWNER)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
hoa Hoa @relation(fields: [hoaId], references: [id], onDelete: Cascade)
unit Unit? @relation(fields: [unitId], references: [id], onDelete: SetNull)
@@unique([userId, hoaId, unitId])
@@map("memberships")
}
/// Immutable audit log for important platform actions.
model AuditLog {
id String @id @default(cuid())
/// Nullable to support system-initiated actions.
userId String?
action String
entityType String
entityId String?
payload Json?
ipAddress String?
userAgent String?
createdAt DateTime @default(now())
@@map("audit_logs")
}

View File

@@ -0,0 +1,52 @@
import { PrismaClient } from '@prisma/client';
import { Pool } from 'pg';
import { PrismaPg } from '@prisma/adapter-pg';
const connectionString = process.env['DATABASE_URL'];
if (!connectionString) {
throw new Error('DATABASE_URL is required for seeding');
}
const pool = new Pool({ connectionString });
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });
/**
* Seeds the local development database with minimal starter data.
* Not intended for production use.
*/
async function main(): Promise<void> {
console.log('Seeding database...');
const hoa = await prisma.hoa.upsert({
where: { slug: 'sunrise-ridge' },
create: {
name: 'Sunrise Ridge HOA',
slug: 'sunrise-ridge',
description: 'Development seed HOA',
},
update: {},
});
console.log(`HOA: ${hoa.name} (${hoa.id})`);
const unit = await prisma.unit.upsert({
where: { hoaId_identifier: { hoaId: hoa.id, identifier: '101' } },
create: {
hoaId: hoa.id,
identifier: '101',
address: '1 Sunrise Ridge Dr, Unit 101',
},
update: {},
});
console.log(`Unit: ${unit.identifier} (${unit.id})`);
console.log('Seeding complete.');
}
main()
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(() => prisma.$disconnect());

44
packages/db/src/client.ts Normal file
View File

@@ -0,0 +1,44 @@
import { PrismaClient } from '@prisma/client';
import { Pool } from 'pg';
import { PrismaPg } from '@prisma/adapter-pg';
declare global {
var __dwellops_prisma: PrismaClient | undefined; // required for global type augmentation
}
/**
* Creates a new PrismaClient instance using the PostgreSQL adapter.
* DATABASE_URL must be set before this module is imported.
*/
function createPrismaClient(): PrismaClient {
const connectionString = process.env['DATABASE_URL'];
if (!connectionString) {
throw new Error('DATABASE_URL environment variable is required for @dwellops/db');
}
const pool = new Pool({ connectionString });
const adapter = new PrismaPg(pool);
return new PrismaClient({
adapter,
log:
process.env['NODE_ENV'] === 'development'
? ['query', 'warn', 'error']
: ['warn', 'error'],
});
}
/**
* Singleton PrismaClient instance.
*
* In development the client is stored on the global object to survive
* hot-module reloads. In production a fresh instance is created once.
*/
const prisma: PrismaClient = global.__dwellops_prisma ?? createPrismaClient();
if (process.env['NODE_ENV'] !== 'production') {
global.__dwellops_prisma = prisma;
}
export { prisma };
export default prisma;

22
packages/db/src/index.ts Normal file
View File

@@ -0,0 +1,22 @@
/**
* @dwellops/db — data access boundary.
*
* Only import from this package for database access. Never import from
* @prisma/client directly in apps/api or apps/web.
*/
export { prisma, default as db } from './client';
// Re-export Prisma types so consumers don't need to depend on @prisma/client.
export type {
User,
Session,
Account,
Verification,
Hoa,
Unit,
Membership,
AuditLog,
Role,
Prisma,
} from '@prisma/client';

View File

@@ -0,0 +1,9 @@
{
"extends": "@dwellops/config/tsconfig/node.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"noEmit": true
},
"include": ["src"]
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,21 @@
{
"name": "@dwellops/i18n",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"typescript": "catalog:",
"@types/node": "catalog:",
"next-intl": "catalog:",
"@dwellops/config": "workspace:*"
}
}

View File

@@ -0,0 +1,31 @@
/**
* Formats a date using the Intl.DateTimeFormat API.
*
* @param date - The date to format.
* @param locale - The locale to use.
* @param options - Optional Intl.DateTimeFormatOptions.
* @returns The formatted date string.
*/
export function formatDate(
date: Date | string,
locale: string,
options?: Intl.DateTimeFormatOptions,
): string {
const d = typeof date === 'string' ? new Date(date) : date;
return new Intl.DateTimeFormat(locale, options).format(d);
}
/**
* Formats a number as a currency string.
*
* @param amount - The amount to format.
* @param currency - The ISO 4217 currency code.
* @param locale - The locale to use.
* @returns The formatted currency string.
*/
export function formatCurrency(amount: number, currency: string, locale: string): string {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
}).format(amount);
}

View File

@@ -0,0 +1,11 @@
/**
* @dwellops/i18n — shared internationalization utilities.
*
* Translation files live alongside components as `translations.json`.
* A shared `common.json` holds truly cross-cutting strings.
* The `scripts/aggregate-translations.ts` script compiles them into
* per-locale message files for next-intl.
*/
export * from './locales';
export * from './format';

View File

@@ -0,0 +1,17 @@
/** All supported locale codes. */
export const locales = ['en'] as const;
/** The default locale. */
export const defaultLocale = 'en' as const;
/** Union type of all supported locale strings. */
export type Locale = (typeof locales)[number];
/**
* Returns true if the provided string is a supported locale.
*
* @param value - The string to check.
*/
export function isLocale(value: string): value is Locale {
return (locales as readonly string[]).includes(value);
}

View File

@@ -0,0 +1,10 @@
{
"extends": "@dwellops/config/tsconfig/base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"noEmit": true,
"lib": ["ES2022", "DOM"]
},
"include": ["src"]
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,23 @@
{
"name": "@dwellops/schemas",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"zod": "catalog:"
},
"devDependencies": {
"typescript": "catalog:",
"@types/node": "catalog:",
"@dwellops/config": "workspace:*"
}
}

View File

@@ -0,0 +1,29 @@
import { z } from 'zod';
import { nonEmptyString } from './common';
/** Magic link request schema. */
export const magicLinkRequestSchema = z.object({
email: z.string().email().toLowerCase(),
redirectTo: z.string().url().optional(),
});
export type MagicLinkRequestInput = z.infer<typeof magicLinkRequestSchema>;
/** Passkey registration options request. */
export const passkeyRegisterRequestSchema = z.object({
userId: nonEmptyString,
});
export type PasskeyRegisterRequestInput = z.infer<typeof passkeyRegisterRequestSchema>;
/** HOA role enum for validation. */
export const hoaRoleSchema = z.enum([
'ADMIN',
'BOARD_MEMBER',
'TREASURER',
'OWNER',
'TENANT',
'VIEWER',
]);
export type HoaRoleInput = z.infer<typeof hoaRoleSchema>;

View File

@@ -0,0 +1,18 @@
import { z } from 'zod';
/** Shared pagination query parameter schema. */
export const paginationSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
});
export type PaginationInput = z.infer<typeof paginationSchema>;
/** Non-empty trimmed string helper. */
export const nonEmptyString = z.string().trim().min(1);
/** Cuid2-style ID string. */
export const idSchema = z.string().cuid2();
/** ISO date string. */
export const isoDateString = z.string().datetime();

View File

@@ -0,0 +1,46 @@
import { z } from 'zod';
import { nonEmptyString, paginationSchema } from './common';
import { hoaRoleSchema } from './auth';
/** Schema for creating an HOA. */
export const createHoaSchema = z.object({
name: nonEmptyString.max(255),
slug: z
.string()
.trim()
.min(1)
.max(100)
.regex(/^[a-z0-9-]+$/, 'Slug must be lowercase alphanumeric with hyphens'),
description: z.string().trim().max(1000).optional(),
});
export type CreateHoaInput = z.infer<typeof createHoaSchema>;
/** Schema for updating an HOA. */
export const updateHoaSchema = createHoaSchema.partial();
export type UpdateHoaInput = z.infer<typeof updateHoaSchema>;
/** Schema for creating a unit within an HOA. */
export const createUnitSchema = z.object({
identifier: nonEmptyString.max(100),
address: z.string().trim().max(500).optional(),
});
export type CreateUnitInput = z.infer<typeof createUnitSchema>;
/** Schema for creating a membership. */
export const createMembershipSchema = z.object({
userId: nonEmptyString,
hoaId: nonEmptyString,
unitId: z.string().optional(),
role: hoaRoleSchema,
});
export type CreateMembershipInput = z.infer<typeof createMembershipSchema>;
/** HOA list query schema. */
export const listHoasSchema = paginationSchema.extend({
search: z.string().trim().optional(),
});
export type ListHoasInput = z.infer<typeof listHoasSchema>;

View File

@@ -0,0 +1,3 @@
export * from './common';
export * from './auth';
export * from './hoa';

View File

@@ -0,0 +1,9 @@
{
"extends": "@dwellops/config/tsconfig/base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"noEmit": true
},
"include": ["src"]
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,3 @@
import { react } from '@dwellops/config/eslint';
export default react;

View File

@@ -0,0 +1,33 @@
{
"name": "@dwellops/test-utils",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"scripts": {
"lint": "eslint src",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@dwellops/types": "workspace:*"
},
"devDependencies": {
"typescript": "catalog:",
"eslint": "catalog:",
"@types/node": "catalog:",
"@dwellops/config": "workspace:*",
"vitest": "catalog:",
"@testing-library/react": "catalog:",
"@testing-library/user-event": "catalog:",
"@testing-library/jest-dom": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:"
}
}

View File

@@ -0,0 +1,96 @@
import type {
AuthUser,
Hoa,
HoaId,
Membership,
MembershipId,
Unit,
UnitId,
UserId,
} from '@dwellops/types';
let _idCounter = 1;
/**
* Generates a deterministic test ID string.
* Resets on module re-import; do not rely on specific values across test files.
*/
function nextId(prefix: string): string {
return `${prefix}_test_${String(_idCounter++).padStart(4, '0')}`;
}
/**
* Resets the ID counter. Call in beforeEach if deterministic IDs matter.
*/
export function resetIdCounter(): void {
_idCounter = 1;
}
/**
* Creates a mock AuthUser. All fields are overridable.
*
* @param overrides - Partial AuthUser fields to override.
*/
export function makeUser(overrides: Partial<AuthUser> = {}): AuthUser {
return {
id: nextId('user') as UserId,
email: 'test@example.com',
name: 'Test User',
emailVerified: true,
image: null,
...overrides,
};
}
/**
* Creates a mock Hoa record.
*
* @param overrides - Partial Hoa fields to override.
*/
export function makeHoa(overrides: Partial<Hoa> = {}): Hoa {
const id = nextId('hoa') as HoaId;
return {
id,
name: 'Test HOA',
slug: `test-hoa-${id}`,
description: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
/**
* Creates a mock Unit record.
*
* @param overrides - Partial Unit fields to override.
*/
export function makeUnit(overrides: Partial<Unit> = {}): Unit {
return {
id: nextId('unit') as UnitId,
hoaId: nextId('hoa') as HoaId,
identifier: '101',
address: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
/**
* Creates a mock Membership record.
*
* @param overrides - Partial Membership fields to override.
*/
export function makeMembership(overrides: Partial<Membership> = {}): Membership {
return {
id: nextId('membership') as MembershipId,
userId: nextId('user') as UserId,
hoaId: nextId('hoa') as HoaId,
unitId: null,
role: 'OWNER',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}

View File

@@ -0,0 +1,2 @@
export * from './factories';
export * from './render';

View File

@@ -0,0 +1,25 @@
import type { ReactElement } from 'react';
import { render as tlRender } from '@testing-library/react';
import type { RenderOptions, RenderResult } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
export type { RenderResult };
/**
* Wrapped render helper that sets up userEvent alongside the render.
* Prefer this over @testing-library/react render directly.
*
* @param ui - The React element to render.
* @param options - Optional Testing Library render options.
* @returns Render result plus a `user` userEvent instance.
*/
export function render(
ui: ReactElement,
options?: RenderOptions,
): RenderResult & { user: ReturnType<typeof userEvent.setup> } {
const user = userEvent.setup();
const result = tlRender(ui, options);
return { ...result, user };
}
export { screen, within, fireEvent, waitFor, act } from '@testing-library/react';

View File

@@ -0,0 +1,10 @@
{
"extends": "@dwellops/config/tsconfig/react-library.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"noEmit": true,
"lib": ["ES2022", "DOM"]
},
"include": ["src"]
}

View File

@@ -0,0 +1,20 @@
{
"name": "@dwellops/types",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"typescript": "catalog:",
"@types/node": "catalog:",
"@dwellops/config": "workspace:*"
}
}

View File

@@ -0,0 +1,55 @@
import type { UserId } from './common';
/**
* HOA roles available within the platform.
* Ordered from most to least privileged.
*/
export type HoaRole = 'ADMIN' | 'BOARD_MEMBER' | 'TREASURER' | 'OWNER' | 'TENANT' | 'VIEWER';
/**
* Resolved user identity after authentication.
*/
export interface AuthUser {
id: UserId;
email: string;
name: string | null;
emailVerified: boolean;
image: string | null;
}
/**
* Permission check context — identifies which HOA and unit the user is operating in.
*/
export interface PermissionContext {
userId: UserId;
hoaId: string;
role: HoaRole;
unitId?: string;
}
/**
* Auth session as surfaced to request context.
*/
export interface RequestSession {
user: AuthUser;
sessionId: string;
expiresAt: Date;
}
/**
* Magic link request payload.
*/
export interface MagicLinkRequest {
email: string;
redirectTo?: string;
}
/**
* OIDC provider configuration (for optional self-hosted OIDC support).
*/
export interface OidcProviderConfig {
issuer: string;
clientId: string;
clientSecret: string;
scopes?: string[];
}

View File

@@ -0,0 +1,52 @@
/**
* Generic paginated result container.
*
* @template T - The type of items in the page.
*/
export interface PaginatedResult<T> {
items: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
/**
* Standard pagination query parameters.
*/
export interface PaginationParams {
page?: number;
pageSize?: number;
}
/**
* Generic API error response shape.
*/
export interface ApiErrorResponse {
statusCode: number;
code: string;
message: string;
details?: unknown;
}
/**
* Generic success response wrapper.
*
* @template T - The type of the response data.
*/
export interface ApiSuccessResponse<T> {
data: T;
}
/** ISO 8601 date string. */
export type ISODateString = string;
/** Opaque branded type helper. */
export type Brand<T, B extends string> = T & { readonly __brand: B };
/** Branded string IDs to prevent accidental mixing of ID types. */
export type UserId = Brand<string, 'UserId'>;
export type HoaId = Brand<string, 'HoaId'>;
export type UnitId = Brand<string, 'UnitId'>;
export type MembershipId = Brand<string, 'MembershipId'>;
export type AuditLogId = Brand<string, 'AuditLogId'>;

59
packages/types/src/hoa.ts Normal file
View File

@@ -0,0 +1,59 @@
import type { HoaId, ISODateString, MembershipId, UnitId, UserId } from './common';
import type { HoaRole } from './auth';
/** HOA record. */
export interface Hoa {
id: HoaId;
name: string;
slug: string;
description: string | null;
createdAt: ISODateString;
updatedAt: ISODateString;
}
/** Individual unit within an HOA. */
export interface Unit {
id: UnitId;
hoaId: HoaId;
identifier: string;
address: string | null;
createdAt: ISODateString;
updatedAt: ISODateString;
}
/** User membership in an HOA with an assigned role and optional unit. */
export interface Membership {
id: MembershipId;
userId: UserId;
hoaId: HoaId;
unitId: UnitId | null;
role: HoaRole;
createdAt: ISODateString;
updatedAt: ISODateString;
}
/** Audit log entry. */
export interface AuditLogEntry {
id: string;
userId: UserId | null;
action: string;
entityType: string;
entityId: string | null;
payload: unknown;
ipAddress: string | null;
userAgent: string | null;
createdAt: ISODateString;
}
/** Audit-able actions. */
export type AuditAction =
| 'user.created'
| 'user.updated'
| 'user.deleted'
| 'membership.created'
| 'membership.updated'
| 'membership.deleted'
| 'hoa.created'
| 'hoa.updated'
| 'unit.created'
| 'unit.updated';

View File

@@ -0,0 +1,3 @@
export * from './common';
export * from './auth';
export * from './hoa';

View File

@@ -0,0 +1,9 @@
{
"extends": "@dwellops/config/tsconfig/base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"noEmit": true
},
"include": ["src"]
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,15 @@
import type { StorybookConfig } from '@storybook/nextjs-vite';
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(ts|tsx)'],
addons: ['@storybook/addon-docs'],
framework: {
name: '@storybook/nextjs-vite',
options: {},
},
docs: {
autodocs: 'tag',
},
};
export default config;

View File

@@ -0,0 +1,18 @@
import type { Preview } from '@storybook/react';
import '../src/tokens/tokens.css';
const preview: Preview = {
parameters: {
layout: 'centered',
backgrounds: {
default: 'page',
values: [
{ name: 'page', value: '#f8f9fa' },
{ name: 'surface', value: '#ffffff' },
{ name: 'dark', value: '#212529' },
],
},
},
};
export default preview;

View File

@@ -0,0 +1,3 @@
import { react } from '@dwellops/config/eslint';
export default react;

47
packages/ui/package.json Normal file
View File

@@ -0,0 +1,47 @@
{
"name": "@dwellops/ui",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./tokens": "./src/tokens/tokens.css",
"./Button": {
"types": "./src/Button/Button.tsx",
"default": "./src/Button/Button.tsx"
},
"./Card": {
"types": "./src/Card/Card.tsx",
"default": "./src/Card/Card.tsx"
}
},
"scripts": {
"typecheck": "tsc --noEmit",
"lint": "eslint src",
"storybook": "storybook dev -p 6006",
"storybook:build": "storybook build"
},
"dependencies": {
"react": "catalog:",
"react-dom": "catalog:"
},
"devDependencies": {
"typescript": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@types/node": "catalog:",
"storybook": "catalog:",
"@storybook/nextjs-vite": "catalog:",
"@storybook/addon-docs": "catalog:",
"@storybook/react": "catalog:",
"eslint": "catalog:",
"typescript-eslint": "catalog:",
"eslint-plugin-react": "catalog:",
"eslint-plugin-react-hooks": "catalog:",
"eslint-plugin-jsx-a11y": "catalog:",
"@dwellops/config": "workspace:*"
}
}

View File

@@ -0,0 +1,150 @@
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
border: var(--border-width-default) solid transparent;
border-radius: var(--border-radius-base);
font-family: var(--font-family-base);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-tight);
cursor: pointer;
text-decoration: none;
transition:
background-color var(--transition-fast),
border-color var(--transition-fast),
color var(--transition-fast),
box-shadow var(--transition-fast);
white-space: nowrap;
user-select: none;
&:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
&:disabled,
&[aria-disabled='true'] {
cursor: not-allowed;
opacity: 0.5;
}
}
/* ── Variants ────────────────────────────────────────────────── */
.primary {
background-color: var(--color-interactive-default);
border-color: var(--color-interactive-default);
color: var(--color-text-inverse);
&:hover:not(:disabled):not([aria-disabled='true']) {
background-color: var(--color-interactive-hover);
border-color: var(--color-interactive-hover);
}
&:active:not(:disabled):not([aria-disabled='true']) {
background-color: var(--color-interactive-active);
border-color: var(--color-interactive-active);
}
}
.secondary {
background-color: var(--color-bg-surface);
border-color: var(--color-border-default);
color: var(--color-text-primary);
&:hover:not(:disabled):not([aria-disabled='true']) {
background-color: var(--color-bg-subtle);
}
}
.ghost {
background-color: transparent;
border-color: transparent;
color: var(--color-interactive-default);
&:hover:not(:disabled):not([aria-disabled='true']) {
background-color: var(--color-brand-50);
}
}
.danger {
background-color: var(--color-error-500);
border-color: var(--color-error-500);
color: var(--color-text-inverse);
&:hover:not(:disabled):not([aria-disabled='true']) {
background-color: var(--color-error-700);
border-color: var(--color-error-700);
}
}
/* ── Sizes ────────────────────────────────────────────────────── */
.sm {
font-size: var(--font-size-sm);
padding: var(--space-1) var(--space-3);
min-height: 2rem;
}
.md {
font-size: var(--font-size-base);
padding: var(--space-2) var(--space-4);
min-height: 2.5rem;
}
.lg {
font-size: var(--font-size-lg);
padding: var(--space-3) var(--space-6);
min-height: 3rem;
}
/* ── Modifiers ────────────────────────────────────────────────── */
.fullWidth {
width: 100%;
}
.loading {
position: relative;
& .label {
opacity: 0;
}
}
/* ── Sub-elements ─────────────────────────────────────────────── */
.icon {
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
.label {
display: inline-flex;
align-items: center;
}
.spinner {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.spinnerDot {
width: 1em;
height: 1em;
border: 2px solid currentcolor;
border-top-color: transparent;
border-radius: var(--border-radius-full);
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,73 @@
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
title: 'Primitives/Button',
component: Button,
tags: ['autodocs'],
argTypes: {
variant: {
control: 'select',
options: ['primary', 'secondary', 'ghost', 'danger'],
},
size: {
control: 'select',
options: ['sm', 'md', 'lg'],
},
loading: { control: 'boolean' },
disabled: { control: 'boolean' },
fullWidth: { control: 'boolean' },
},
parameters: {
docs: {
description: {
component: `
Core interactive button primitive. Follows WCAG 2.2 AA requirements:
visible focus ring, aria-disabled, aria-busy states.
`,
},
},
},
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Primary: Story = {
args: { variant: 'primary', children: 'Save changes' },
};
export const Secondary: Story = {
args: { variant: 'secondary', children: 'Cancel' },
};
export const Ghost: Story = {
args: { variant: 'ghost', children: 'Learn more' },
};
export const Danger: Story = {
args: { variant: 'danger', children: 'Delete record' },
};
export const Small: Story = {
args: { size: 'sm', children: 'Small button' },
};
export const Large: Story = {
args: { size: 'lg', children: 'Large button' },
};
export const Loading: Story = {
args: { loading: true, children: 'Saving…' },
};
export const Disabled: Story = {
args: { disabled: true, children: 'Not available' },
};
export const FullWidth: Story = {
args: { fullWidth: true, children: 'Full-width action' },
parameters: {
layout: 'padded',
},
};

View File

@@ -0,0 +1,87 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import styles from './Button.module.css';
export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
export type ButtonSize = 'sm' | 'md' | 'lg';
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/** Visual variant of the button. */
variant?: ButtonVariant;
/** Size of the button. */
size?: ButtonSize;
/** Renders a full-width block button. */
fullWidth?: boolean;
/** Shows a loading spinner and disables interaction. */
loading?: boolean;
/** Icon placed before the label. */
leadingIcon?: ReactNode;
/** Icon placed after the label. */
trailingIcon?: ReactNode;
children: ReactNode;
}
/**
* Core interactive button primitive.
*
* All interactive affordances follow WCAG 2.2 AA:
* - Minimum 44 × 44 px touch target (sm/md/lg).
* - Visible focus ring via `--focus-ring` token.
* - `aria-disabled` and `aria-busy` set appropriately.
*
* @example
* ```tsx
* <Button variant="primary" onClick={handleSave}>Save changes</Button>
* ```
*/
export function Button({
variant = 'primary',
size = 'md',
fullWidth = false,
loading = false,
leadingIcon,
trailingIcon,
disabled,
children,
className,
...rest
}: ButtonProps) {
const isDisabled = disabled || loading;
return (
<button
{...rest}
disabled={isDisabled}
aria-disabled={isDisabled}
aria-busy={loading}
className={[
styles.button,
styles[variant],
styles[size],
fullWidth ? styles.fullWidth : '',
loading ? styles.loading : '',
className ?? '',
]
.filter(Boolean)
.join(' ')}
>
{leadingIcon && (
<span className={styles.icon} aria-hidden="true">
{leadingIcon}
</span>
)}
<span className={styles.label}>{children}</span>
{trailingIcon && (
<span className={styles.icon} aria-hidden="true">
{trailingIcon}
</span>
)}
{loading && (
<span className={styles.spinner} role="status" aria-label="Loading">
<span className={styles.spinnerDot} />
</span>
)}
</button>
);
}
export default Button;

View File

@@ -0,0 +1,44 @@
.card {
background-color: var(--color-bg-surface);
border: var(--border-width-default) solid var(--color-border-default);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.header {
padding: var(--space-4) var(--space-6);
border-block-end: var(--border-width-default) solid var(--color-border-subtle);
}
.title {
margin: 0;
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
line-height: var(--line-height-tight);
}
.description {
margin: var(--space-1) 0 0;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
line-height: var(--line-height-normal);
}
.body {
padding: var(--space-6);
}
.noPadding {
padding: 0;
}
.footer {
padding: var(--space-4) var(--space-6);
border-block-start: var(--border-width-default) solid var(--color-border-subtle);
background-color: var(--color-bg-subtle);
display: flex;
align-items: center;
gap: var(--space-3);
}

View File

@@ -0,0 +1,65 @@
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from '../Button/Button';
import { Card } from './Card';
const meta: Meta<typeof Card> = {
title: 'Primitives/Card',
component: Card,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component: 'Surface container for grouped content.',
},
},
},
};
export default meta;
type Story = StoryObj<typeof Card>;
export const Default: Story = {
args: {
title: 'Unit 101',
description: 'Sunrise Ridge HOA',
children: 'Resident information and details would appear here.',
},
};
export const WithFooter: Story = {
args: {
title: 'Pending dues',
description: 'Q1 2026 assessment',
children: 'Outstanding balance: $350.00',
footer: (
<>
<Button variant="primary" size="sm">
Pay now
</Button>
<Button variant="ghost" size="sm">
View statement
</Button>
</>
),
},
};
export const NoPadding: Story = {
args: {
title: 'Document list',
noPadding: true,
children: (
<ul style={{ margin: 0, padding: '1rem 1.5rem', listStyle: 'none' }}>
<li>HOA Bylaws 2025.pdf</li>
<li>Meeting Minutes Q4 2025.pdf</li>
</ul>
),
},
};
export const ContentOnly: Story = {
args: {
children: 'A simple card with no title or footer.',
},
};

View File

@@ -0,0 +1,60 @@
import type { HTMLAttributes, ReactNode } from 'react';
import styles from './Card.module.css';
export interface CardProps extends HTMLAttributes<HTMLElement> {
/** Card heading text. */
title?: string;
/** Optional description or metadata shown below the title. */
description?: string;
/** Actions rendered in the card footer (e.g. buttons). */
footer?: ReactNode;
/** Removes the default padding from the content area. */
noPadding?: boolean;
children?: ReactNode;
}
/**
* Surface container for grouped content.
*
* Uses a `<article>` element by default for semantic grouping.
* Override with `as` if a different element is needed.
*
* @example
* ```tsx
* <Card title="Unit 101" description="Sunrise Ridge HOA">
* <p>Resident details here.</p>
* </Card>
* ```
*/
export function Card({
title,
description,
footer,
noPadding = false,
children,
className,
...rest
}: CardProps) {
return (
<article {...rest} className={[styles.card, className ?? ''].filter(Boolean).join(' ')}>
{(title || description) && (
<header className={styles.header}>
{title && <h3 className={styles.title}>{title}</h3>}
{description && <p className={styles.description}>{description}</p>}
</header>
)}
<div
className={[styles.body, noPadding ? styles.noPadding : '']
.filter(Boolean)
.join(' ')}
>
{children}
</div>
{footer && <footer className={styles.footer}>{footer}</footer>}
</article>
);
}
export default Card;

5
packages/ui/src/css.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/** TypeScript declarations for CSS Module files. */
declare module '*.module.css' {
const styles: Record<string, string>;
export default styles;
}

5
packages/ui/src/index.ts Normal file
View File

@@ -0,0 +1,5 @@
export { Button } from './Button/Button';
export type { ButtonProps, ButtonVariant, ButtonSize } from './Button/Button';
export { Card } from './Card/Card';
export type { CardProps } from './Card/Card';

View File

@@ -0,0 +1,140 @@
/**
* Design token definitions for dwellops-platform.
* All values are expressed as CSS custom properties.
* Import this file once at the app root, then reference tokens throughout.
*/
:root {
/* ── Color: Primitives ───────────────────────────────────────── */
--color-neutral-0: #ffffff;
--color-neutral-50: #f8f9fa;
--color-neutral-100: #f1f3f5;
--color-neutral-200: #e9ecef;
--color-neutral-300: #dee2e6;
--color-neutral-400: #ced4da;
--color-neutral-500: #adb5bd;
--color-neutral-600: #6c757d;
--color-neutral-700: #495057;
--color-neutral-800: #343a40;
--color-neutral-900: #212529;
--color-neutral-1000: #000000;
--color-brand-50: #e8f4fd;
--color-brand-100: #bee3f8;
--color-brand-200: #90cdf4;
--color-brand-300: #63b3ed;
--color-brand-400: #4299e1;
--color-brand-500: #3182ce;
--color-brand-600: #2b6cb0;
--color-brand-700: #2c5282;
--color-brand-800: #2a4365;
--color-brand-900: #1a365d;
--color-success-50: #f0fdf4;
--color-success-500: #22c55e;
--color-success-700: #15803d;
--color-warning-50: #fffbeb;
--color-warning-500: #f59e0b;
--color-warning-700: #b45309;
--color-error-50: #fef2f2;
--color-error-500: #ef4444;
--color-error-700: #b91c1c;
/* ── Color: Semantic ─────────────────────────────────────────── */
--color-bg-page: var(--color-neutral-50);
--color-bg-surface: var(--color-neutral-0);
--color-bg-subtle: var(--color-neutral-100);
--color-bg-muted: var(--color-neutral-200);
--color-text-primary: var(--color-neutral-900);
--color-text-secondary: var(--color-neutral-700);
--color-text-muted: var(--color-neutral-600);
--color-text-inverse: var(--color-neutral-0);
--color-border-default: var(--color-neutral-300);
--color-border-subtle: var(--color-neutral-200);
--color-border-strong: var(--color-neutral-500);
--color-interactive-default: var(--color-brand-600);
--color-interactive-hover: var(--color-brand-700);
--color-interactive-active: var(--color-brand-800);
--color-interactive-focus: var(--color-brand-500);
--color-interactive-disabled: var(--color-neutral-400);
/* ── Spacing ─────────────────────────────────────────────────── */
--space-0: 0;
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-10: 2.5rem;
--space-12: 3rem;
--space-16: 4rem;
--space-20: 5rem;
--space-24: 6rem;
/* ── Typography ──────────────────────────────────────────────── */
--font-family-base:
system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-family-mono: ui-monospace, 'Cascadia Code', 'Fira Code', monospace;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-2xl: 1.5rem;
--font-size-3xl: 1.875rem;
--font-size-4xl: 2.25rem;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
--line-height-tight: 1.25;
--line-height-snug: 1.375;
--line-height-normal: 1.5;
--line-height-relaxed: 1.625;
/* ── Border ──────────────────────────────────────────────────── */
--border-width-default: 1px;
--border-width-medium: 2px;
--border-radius-sm: 0.25rem;
--border-radius-base: 0.375rem;
--border-radius-md: 0.5rem;
--border-radius-lg: 0.75rem;
--border-radius-xl: 1rem;
--border-radius-full: 9999px;
/* ── Shadow ──────────────────────────────────────────────────── */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-base: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
/* ── Transition ──────────────────────────────────────────────── */
--transition-fast: 100ms ease-in-out;
--transition-base: 200ms ease-in-out;
--transition-slow: 300ms ease-in-out;
/* ── Focus ring ──────────────────────────────────────────────── */
--focus-ring: 0 0 0 3px var(--color-brand-300);
/* ── Z-index ─────────────────────────────────────────────────── */
--z-below: -1;
--z-base: 0;
--z-raised: 10;
--z-dropdown: 100;
--z-sticky: 200;
--z-overlay: 300;
--z-modal: 400;
--z-toast: 500;
--z-tooltip: 600;
}

View File

@@ -0,0 +1,9 @@
{
"extends": "@dwellops/config/tsconfig/react-library.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"noEmit": true
},
"include": ["src"]
}

File diff suppressed because one or more lines are too long