Initial commit
This commit is contained in:
68
packages/config/eslint/index.js
Normal file
68
packages/config/eslint/index.js
Normal 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;
|
||||
40
packages/config/package.json
Normal file
40
packages/config/package.json
Normal 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:"
|
||||
}
|
||||
}
|
||||
19
packages/config/prettier/index.js
Normal file
19
packages/config/prettier/index.js
Normal 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;
|
||||
31
packages/config/stylelint/index.js
Normal file
31
packages/config/stylelint/index.js
Normal 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;
|
||||
20
packages/config/tsconfig/base.json
Normal file
20
packages/config/tsconfig/base.json
Normal 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
|
||||
}
|
||||
}
|
||||
14
packages/config/tsconfig/nextjs.json
Normal file
14
packages/config/tsconfig/nextjs.json
Normal 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" }]
|
||||
}
|
||||
}
|
||||
10
packages/config/tsconfig/node.json
Normal file
10
packages/config/tsconfig/node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "./base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler"
|
||||
}
|
||||
}
|
||||
9
packages/config/tsconfig/react-library.json
Normal file
9
packages/config/tsconfig/react-library.json
Normal 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"
|
||||
}
|
||||
}
|
||||
37
packages/config/vitest/index.js
Normal file
37
packages/config/vitest/index.js
Normal 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();
|
||||
19
packages/db/.config/prisma.ts
Normal file
19
packages/db/.config/prisma.ts
Normal 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
1
packages/db/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
DATABASE_URL="postgresql://dwellops:dwellops@localhost:5432/dwellops_dev?schema=public"
|
||||
3
packages/db/eslint.config.js
Normal file
3
packages/db/eslint.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import { base } from '@dwellops/config/eslint';
|
||||
|
||||
export default base;
|
||||
41
packages/db/package.json
Normal file
41
packages/db/package.json
Normal 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:"
|
||||
}
|
||||
}
|
||||
154
packages/db/prisma/schema.prisma
Normal file
154
packages/db/prisma/schema.prisma
Normal 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")
|
||||
}
|
||||
52
packages/db/prisma/seed.ts
Normal file
52
packages/db/prisma/seed.ts
Normal 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
44
packages/db/src/client.ts
Normal 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
22
packages/db/src/index.ts
Normal 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';
|
||||
9
packages/db/tsconfig.json
Normal file
9
packages/db/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@dwellops/config/tsconfig/node.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
1
packages/db/tsconfig.tsbuildinfo
Normal file
1
packages/db/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
21
packages/i18n/package.json
Normal file
21
packages/i18n/package.json
Normal 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:*"
|
||||
}
|
||||
}
|
||||
31
packages/i18n/src/format.ts
Normal file
31
packages/i18n/src/format.ts
Normal 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);
|
||||
}
|
||||
11
packages/i18n/src/index.ts
Normal file
11
packages/i18n/src/index.ts
Normal 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';
|
||||
17
packages/i18n/src/locales.ts
Normal file
17
packages/i18n/src/locales.ts
Normal 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);
|
||||
}
|
||||
10
packages/i18n/tsconfig.json
Normal file
10
packages/i18n/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@dwellops/config/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"noEmit": true,
|
||||
"lib": ["ES2022", "DOM"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
1
packages/i18n/tsconfig.tsbuildinfo
Normal file
1
packages/i18n/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
23
packages/schemas/package.json
Normal file
23
packages/schemas/package.json
Normal 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:*"
|
||||
}
|
||||
}
|
||||
29
packages/schemas/src/auth.ts
Normal file
29
packages/schemas/src/auth.ts
Normal 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>;
|
||||
18
packages/schemas/src/common.ts
Normal file
18
packages/schemas/src/common.ts
Normal 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();
|
||||
46
packages/schemas/src/hoa.ts
Normal file
46
packages/schemas/src/hoa.ts
Normal 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>;
|
||||
3
packages/schemas/src/index.ts
Normal file
3
packages/schemas/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './common';
|
||||
export * from './auth';
|
||||
export * from './hoa';
|
||||
9
packages/schemas/tsconfig.json
Normal file
9
packages/schemas/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@dwellops/config/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
1
packages/schemas/tsconfig.tsbuildinfo
Normal file
1
packages/schemas/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
3
packages/test-utils/eslint.config.js
Normal file
3
packages/test-utils/eslint.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import { react } from '@dwellops/config/eslint';
|
||||
|
||||
export default react;
|
||||
33
packages/test-utils/package.json
Normal file
33
packages/test-utils/package.json
Normal 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:"
|
||||
}
|
||||
}
|
||||
96
packages/test-utils/src/factories.ts
Normal file
96
packages/test-utils/src/factories.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
2
packages/test-utils/src/index.ts
Normal file
2
packages/test-utils/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './factories';
|
||||
export * from './render';
|
||||
25
packages/test-utils/src/render.tsx
Normal file
25
packages/test-utils/src/render.tsx
Normal 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';
|
||||
10
packages/test-utils/tsconfig.json
Normal file
10
packages/test-utils/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@dwellops/config/tsconfig/react-library.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"noEmit": true,
|
||||
"lib": ["ES2022", "DOM"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
20
packages/types/package.json
Normal file
20
packages/types/package.json
Normal 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:*"
|
||||
}
|
||||
}
|
||||
55
packages/types/src/auth.ts
Normal file
55
packages/types/src/auth.ts
Normal 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[];
|
||||
}
|
||||
52
packages/types/src/common.ts
Normal file
52
packages/types/src/common.ts
Normal 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
59
packages/types/src/hoa.ts
Normal 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';
|
||||
3
packages/types/src/index.ts
Normal file
3
packages/types/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './common';
|
||||
export * from './auth';
|
||||
export * from './hoa';
|
||||
9
packages/types/tsconfig.json
Normal file
9
packages/types/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@dwellops/config/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
1
packages/types/tsconfig.tsbuildinfo
Normal file
1
packages/types/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
15
packages/ui/.storybook/main.ts
Normal file
15
packages/ui/.storybook/main.ts
Normal 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;
|
||||
18
packages/ui/.storybook/preview.ts
Normal file
18
packages/ui/.storybook/preview.ts
Normal 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;
|
||||
3
packages/ui/eslint.config.js
Normal file
3
packages/ui/eslint.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import { react } from '@dwellops/config/eslint';
|
||||
|
||||
export default react;
|
||||
47
packages/ui/package.json
Normal file
47
packages/ui/package.json
Normal 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:*"
|
||||
}
|
||||
}
|
||||
150
packages/ui/src/Button/Button.module.css
Normal file
150
packages/ui/src/Button/Button.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
73
packages/ui/src/Button/Button.stories.tsx
Normal file
73
packages/ui/src/Button/Button.stories.tsx
Normal 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',
|
||||
},
|
||||
};
|
||||
87
packages/ui/src/Button/Button.tsx
Normal file
87
packages/ui/src/Button/Button.tsx
Normal 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;
|
||||
44
packages/ui/src/Card/Card.module.css
Normal file
44
packages/ui/src/Card/Card.module.css
Normal 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);
|
||||
}
|
||||
65
packages/ui/src/Card/Card.stories.tsx
Normal file
65
packages/ui/src/Card/Card.stories.tsx
Normal 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.',
|
||||
},
|
||||
};
|
||||
60
packages/ui/src/Card/Card.tsx
Normal file
60
packages/ui/src/Card/Card.tsx
Normal 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
5
packages/ui/src/css.d.ts
vendored
Normal 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
5
packages/ui/src/index.ts
Normal 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';
|
||||
140
packages/ui/src/tokens/tokens.css
Normal file
140
packages/ui/src/tokens/tokens.css
Normal 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;
|
||||
}
|
||||
9
packages/ui/tsconfig.json
Normal file
9
packages/ui/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@dwellops/config/tsconfig/react-library.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
1
packages/ui/tsconfig.tsbuildinfo
Normal file
1
packages/ui/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user