feat(api,ui): MFA/TOTP, Inventory, Support, HR-Timeclock, Fiscal, Billing e Design System
Leva grande, pedida como "pelo menos mais 20 etapas, use subagentes":
fecha a pendência de MFA/TOTP da Fase 1 (feito diretamente, autenticação é
sensível demais para delegar) e lança 5 subagentes em paralelo em 2 ondas
(eden-inventory, eden-support, eden-hr-timeclock; depois eden-finance,
eden-fiscal, eden-frontend), cada um em worktree isolado, com integração
seletiva manual (barrel, app.module.ts, migrations renomeadas/aplicadas,
build/typecheck/teste ao vivo antes de cada merge).
## Backend
- MFA/TOTP (`apps/api/src/auth/mfa.service.ts`, `crypto/field-encryption.service.ts`):
login em duas etapas quando `mfa_enabled=true`, `FieldEncryptionService`
genérico (AES-256-GCM versionado, ADR-0005) reaproveitado por outros
segredos reversíveis (ex. credencial de equipamento Control iD).
- Inventory: warehouses, stock_movements (ledger append-only), serialized_assets,
asset_assignments, stock_reservations — invariante "serial/patrimônio/MAC
nunca duplicado entre ativos ativos" reforçada por índices únicos parciais
no banco (Master Prompt §15.3).
- Support/Service Desk: tickets com máquina de estados, SLA com pausa/retomada
em `waiting_customer`/`waiting_third_party` (funções puras testáveis).
- HR-Timeclock (fundação): dispositivos Control iD (senha cifrada), import de
AFD com parser + CRC-16/KERMIT, `afd_records` imutável sem exceção nem
para super_admin (sem válvula de escape, diferente de audit_log).
- Fiscal: NCM/CFOP/municípios/naturezas de operação (eden.md módulo 5, MVP de
5 das ~19 tabelas), product_fiscal_profiles com invariante de único
profile ativo por billing_component.
- Billing: subscription→billing_run→invoice idempotente (regenerar em draft
substitui, nunca duplica) e imutável após consolidar (ADR-0008: nunca
confunde faturamento/documento fiscal/recebimento).
- FKs cruzadas fechadas: quotes/client_registrations/contracts.
## Frontend — primeira leva
- `packages/ui`: Design System real extraído do tema DreamsERP comprado —
achado que mudou o escopo: o tema já vem em React+Tailwind (não só
Angular), tokens visuais reais portados (cores, Geist como fonte real,
divergindo da doc antiga que citava Nunito/Poppins).
- `apps/core-web`: login real (com suporte a MFA), sessão, shell autenticado
via `AppShell` — substitui a tela estática "Bootstrap da Fase 1".
## Bugs reais encontrados e corrigidos (todos via teste ao vivo, não leitura)
1. **Segurança**: o token de desafio MFA era aceito como access token normal
pelo `JwtAuthGuard` (mesma assinatura, nenhuma checagem da claim
`purpose`) — bypass total do segundo fator. Explorado e fechado ao vivo
contra o container real; teste de regressão em `test/mfa.spec.ts`.
2. Crase solta dentro de comentário SQL na migration de hr-timeclock quebrava
o template literal JS (`SyntaxError` ao rodar a migration).
3. CORS ausente por completo na API.
4. `turbo.json` não declarava `VITE_API_BASE_URL` como input de build — o
Turborepo cacheava o bundle do frontend ignorando mudança na env var.
5. `compose.yaml` usava `${VITE_API_BASE_URL:-/api/v1}`, que colidia com a
variável de mesmo nome do `.env` local — o default nunca era aplicado.
6. Não existia `.dockerignore` — todo build copiava node_modules/.git/
worktrees de subagentes (665MB) para dentro do contexto Docker.
Decisão do operador incorporada: a API nunca tem domínio público próprio
(só IP interno) — os 3 frontends chamam por caminho relativo `/api/v1`,
proxy externo roteia same-origin; CORS só é relevante para dev local.
Verificado: `pnpm turbo run typecheck build` (28/28), `pnpm --filter
@eden/api test` (30/30, cresceu de 15), todo o lote testado ao vivo via
curl contra os containers reais reconstruídos, incluindo os 6 bugs acima
já fechados e confirmados, e a trilha de auditoria conferida no Postgres.
Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
29
packages/ui/src/components/Alert.tsx
Normal file
29
packages/ui/src/components/Alert.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import { cn } from "../utils/cn.js";
|
||||
|
||||
export type AlertVariant = "info" | "success" | "warning" | "error";
|
||||
|
||||
const variantClasses: Record<AlertVariant, string> = {
|
||||
info: "bg-info-transparent border-info-200 text-info-900",
|
||||
success: "bg-success-transparent border-success-200 text-success-900",
|
||||
warning: "bg-warning-transparent border-warning-200 text-warning-900",
|
||||
error: "bg-danger-transparent border-danger-200 text-danger-900",
|
||||
};
|
||||
|
||||
export interface AlertProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
|
||||
variant?: AlertVariant;
|
||||
title?: ReactNode;
|
||||
}
|
||||
|
||||
export function Alert({ variant = "info", title, className, children, role, ...rest }: AlertProps) {
|
||||
return (
|
||||
<div
|
||||
role={role ?? (variant === "error" ? "alert" : "status")}
|
||||
className={cn("rounded-md border px-4 py-3 text-sm", variantClasses[variant], className)}
|
||||
{...rest}
|
||||
>
|
||||
{title ? <p className="mb-0.5 font-semibold">{title}</p> : null}
|
||||
{children ? <div>{children}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
packages/ui/src/components/AppShell.tsx
Normal file
120
packages/ui/src/components/AppShell.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { cn } from "../utils/cn.js";
|
||||
|
||||
export interface AppShellNavItem {
|
||||
key: string;
|
||||
label: string;
|
||||
href: string;
|
||||
icon?: ReactNode;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
interface NavLinkRenderProps {
|
||||
href: string;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export interface AppShellProps {
|
||||
/** e.g. "EDEN Core" — shown next to the logo and used as the img alt text. */
|
||||
appName: string;
|
||||
logoSrc?: string;
|
||||
navItems: AppShellNavItem[];
|
||||
userName?: string;
|
||||
userRole?: string;
|
||||
onLogout?: () => void;
|
||||
children: ReactNode;
|
||||
/**
|
||||
* Router-agnostic navigation: packages/ui doesn't depend on react-router.
|
||||
* Defaults to a plain `<a>` (full page reload); pass e.g.
|
||||
* `({ href, className, children }) => <Link to={href} className={className}>{children}</Link>`
|
||||
* from the consuming app to get client-side routing.
|
||||
*/
|
||||
LinkComponent?: ComponentType<NavLinkRenderProps>;
|
||||
}
|
||||
|
||||
function DefaultLink({ href, className, children }: NavLinkRenderProps) {
|
||||
return (
|
||||
<a href={href} className={className}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Visually inspired by the DreamsERP theme's sidebar/topbar
|
||||
* (react/src/components/common-sidebar, common-header) — sidebar with
|
||||
* logo + dark active nav item, white topbar with a bordered bottom edge —
|
||||
* without importing any of the theme's Redux/routing code.
|
||||
*/
|
||||
export function AppShell({
|
||||
appName,
|
||||
logoSrc,
|
||||
navItems,
|
||||
userName,
|
||||
userRole,
|
||||
onLogout,
|
||||
children,
|
||||
LinkComponent = DefaultLink,
|
||||
}: AppShellProps) {
|
||||
return (
|
||||
<div className="flex min-h-screen bg-light">
|
||||
<aside className="hidden w-sidebar shrink-0 flex-col border-r border-border-color bg-white lg:flex">
|
||||
<div className="flex h-topbar items-center gap-2 border-b border-border-color px-4">
|
||||
{logoSrc ? <img src={logoSrc} alt={appName} className="h-7 w-auto" /> : null}
|
||||
<span className="text-sm font-bold text-title">{appName}</span>
|
||||
</div>
|
||||
<nav className="flex flex-1 flex-col gap-1 overflow-y-auto p-3" aria-label="Navegação principal">
|
||||
{navItems.map((item) => (
|
||||
<LinkComponent
|
||||
key={item.key}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors duration-150",
|
||||
item.active
|
||||
? "bg-dark text-white"
|
||||
: "text-gray-900 hover:bg-light-700 hover:text-primary",
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</LinkComponent>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="flex h-topbar items-center justify-between gap-3 border-b border-border-color bg-white px-4">
|
||||
<div className="flex items-center gap-2 lg:hidden">
|
||||
{logoSrc ? <img src={logoSrc} alt={appName} className="h-6 w-auto" /> : null}
|
||||
<span className="text-sm font-bold text-title">{appName}</span>
|
||||
</div>
|
||||
<div className="ms-auto flex items-center gap-3">
|
||||
{userName ? (
|
||||
<div className="flex items-center gap-2 rounded-md border border-border-color px-2 py-1">
|
||||
<span className="flex size-7 items-center justify-center rounded-full bg-dark text-xs font-semibold text-white">
|
||||
{userName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<div className="hidden text-left leading-tight sm:block">
|
||||
<p className="text-xs font-semibold text-title">{userName}</p>
|
||||
{userRole ? <p className="text-[11px] text-default">{userRole}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{onLogout ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
className="rounded-md border border-border-color px-3 py-1.5 text-xs font-semibold text-default hover:bg-light hover:text-danger"
|
||||
>
|
||||
Sair
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 p-6">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
packages/ui/src/components/Badge.tsx
Normal file
33
packages/ui/src/components/Badge.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { cn } from "../utils/cn.js";
|
||||
|
||||
export type BadgeVariant = "primary" | "secondary" | "success" | "warning" | "danger" | "info" | "neutral";
|
||||
|
||||
const variantClasses: Record<BadgeVariant, string> = {
|
||||
primary: "bg-primary-transparent text-primary-900",
|
||||
secondary: "bg-secondary-transparent text-secondary-900",
|
||||
success: "bg-success-transparent text-success-900",
|
||||
warning: "bg-warning-transparent text-warning-900",
|
||||
danger: "bg-danger-transparent text-danger-900",
|
||||
info: "bg-info-transparent text-info-900",
|
||||
neutral: "bg-light text-gray-900",
|
||||
};
|
||||
|
||||
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
variant?: BadgeVariant;
|
||||
}
|
||||
|
||||
export function Badge({ variant = "neutral", className, children, ...rest }: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-semibold",
|
||||
variantClasses[variant],
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
54
packages/ui/src/components/Button.tsx
Normal file
54
packages/ui/src/components/Button.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { forwardRef } from "react";
|
||||
import type { ButtonHTMLAttributes } from "react";
|
||||
import { cn } from "../utils/cn.js";
|
||||
|
||||
export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
|
||||
export type ButtonSize = "sm" | "md";
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
/** Shows an inline spinner and disables the button — for in-flight submits (e.g. login). */
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const variantClasses: Record<ButtonVariant, string> = {
|
||||
// Theme source (login.tsx) uses a dark CTA that hovers to the primary-hover
|
||||
// shade, not a flat primary fill — kept here as-is.
|
||||
primary: "bg-dark text-white hover:bg-primary-hover",
|
||||
secondary: "bg-white text-title border border-border-color hover:bg-light",
|
||||
danger: "bg-danger text-white hover:bg-danger-hover",
|
||||
ghost: "bg-transparent text-default hover:bg-light",
|
||||
};
|
||||
|
||||
const sizeClasses: Record<ButtonSize, string> = {
|
||||
sm: "text-xs px-3 py-1.5",
|
||||
md: "text-sm px-4 py-2.5",
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
{ variant = "primary", size = "md", loading = false, disabled, className, children, ...rest },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
disabled={disabled || loading}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md font-semibold cursor-pointer transition-colors duration-200 ease-in-out disabled:cursor-not-allowed disabled:opacity-60",
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{loading ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="size-4 animate-spin rounded-full border-2 border-current border-t-transparent"
|
||||
/>
|
||||
) : null}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
22
packages/ui/src/components/Card.tsx
Normal file
22
packages/ui/src/components/Card.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import { cn } from "../utils/cn.js";
|
||||
|
||||
export interface CardProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
|
||||
title?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
/** Theme source (auth-pages/login.tsx): `bg-white border border-border-color rounded-md shadow-sm`. */
|
||||
export function Card({ title, actions, className, children, ...rest }: CardProps) {
|
||||
return (
|
||||
<div className={cn("rounded-md border border-border-color bg-white shadow-sm", className)} {...rest}>
|
||||
{title || actions ? (
|
||||
<div className="flex items-center justify-between gap-3 border-b border-border-color px-5 py-4">
|
||||
{title ? <h3 className="!text-base font-semibold text-title">{title}</h3> : <span />}
|
||||
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="p-5">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
packages/ui/src/components/Input.tsx
Normal file
50
packages/ui/src/components/Input.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { forwardRef, useId } from "react";
|
||||
import type { InputHTMLAttributes } from "react";
|
||||
import { cn } from "../utils/cn.js";
|
||||
|
||||
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
/** Validation/server error message — shown below the field, field border turns danger. */
|
||||
error?: string;
|
||||
/** Helper text shown below the field when there's no error. */
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
{ label, error, hint, id, className, ...rest },
|
||||
ref,
|
||||
) {
|
||||
const generatedId = useId();
|
||||
const inputId = id ?? generatedId;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{label ? (
|
||||
<label htmlFor={inputId} className="text-sm font-semibold text-gray-900">
|
||||
{label}
|
||||
</label>
|
||||
) : null}
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? `${inputId}-error` : hint ? `${inputId}-hint` : undefined}
|
||||
className={cn(
|
||||
"w-full rounded-md border bg-white px-3 py-2.5 text-sm text-title placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-primary/30",
|
||||
error ? "border-danger" : "border-border-color",
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
{error ? (
|
||||
<p id={`${inputId}-error`} className="text-xs text-danger">
|
||||
{error}
|
||||
</p>
|
||||
) : hint ? (
|
||||
<p id={`${inputId}-hint`} className="text-xs text-default">
|
||||
{hint}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
20
packages/ui/src/components/PageHeader.tsx
Normal file
20
packages/ui/src/components/PageHeader.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import { cn } from "../utils/cn.js";
|
||||
|
||||
export interface PageHeaderProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export function PageHeader({ title, description, actions, className, ...rest }: PageHeaderProps) {
|
||||
return (
|
||||
<div className={cn("flex flex-wrap items-start justify-between gap-3 pb-4", className)} {...rest}>
|
||||
<div>
|
||||
<h1 className="!text-xl font-bold text-title">{title}</h1>
|
||||
{description ? <p className="mt-1 text-sm text-default">{description}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,31 @@
|
||||
/**
|
||||
* EDEN Design System, derived from the purchased DreamsERP theme
|
||||
* (tema_do_Eden.zip) — no components ported yet. See
|
||||
* (tema_do_Eden.zip → dreamserp-v1.0.1/react.zip). See
|
||||
* docs/project-inventory.md §2 and the eden-frontend agent
|
||||
* (.claude/agents/eden-frontend.md) for the extraction plan.
|
||||
* (.claude/agents/eden-frontend.md) for the extraction plan and
|
||||
* `./tailwind-preset` for the extracted visual tokens.
|
||||
*/
|
||||
export const UI_PACKAGE_VERSION = "0.1.0";
|
||||
export const UI_PACKAGE_VERSION = "0.2.0";
|
||||
|
||||
export { Button } from "./components/Button.js";
|
||||
export type { ButtonProps, ButtonVariant, ButtonSize } from "./components/Button.js";
|
||||
|
||||
export { Input } from "./components/Input.js";
|
||||
export type { InputProps } from "./components/Input.js";
|
||||
|
||||
export { Card } from "./components/Card.js";
|
||||
export type { CardProps } from "./components/Card.js";
|
||||
|
||||
export { Badge } from "./components/Badge.js";
|
||||
export type { BadgeProps, BadgeVariant } from "./components/Badge.js";
|
||||
|
||||
export { Alert } from "./components/Alert.js";
|
||||
export type { AlertProps, AlertVariant } from "./components/Alert.js";
|
||||
|
||||
export { PageHeader } from "./components/PageHeader.js";
|
||||
export type { PageHeaderProps } from "./components/PageHeader.js";
|
||||
|
||||
export { AppShell } from "./components/AppShell.js";
|
||||
export type { AppShellProps, AppShellNavItem } from "./components/AppShell.js";
|
||||
|
||||
export { cn } from "./utils/cn.js";
|
||||
|
||||
231
packages/ui/src/tailwind-preset.ts
Normal file
231
packages/ui/src/tailwind-preset.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* EDEN Tailwind preset — visual tokens extracted from the purchased
|
||||
* DreamsERP theme (tema_do_Eden.zip → dreamserp-v1.0.1/react.zip →
|
||||
* react/src/style/css/style.css, Tailwind v4 `@theme` block).
|
||||
*
|
||||
* The theme itself ships as Tailwind v4 (CSS-first `@theme`), but our apps
|
||||
* are on Tailwind v3, so the same token values are re-expressed here as a
|
||||
* plain JS/TS preset consumed via `presets: [require("@eden/ui/tailwind-preset")]`
|
||||
* (or the ESM equivalent) in each app's own tailwind.config.js.
|
||||
*
|
||||
* Do not hand-edit color hexes without re-checking the source file — these
|
||||
* are copied verbatim, not invented.
|
||||
*/
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
type PresetTheme = Config["theme"];
|
||||
|
||||
const withHover = (base: Record<string, string>, hover: string) => ({
|
||||
...base,
|
||||
hover,
|
||||
});
|
||||
|
||||
const primary = withHover(
|
||||
{
|
||||
DEFAULT: "#0F766E",
|
||||
100: "#D7E5E3",
|
||||
200: "#C3D8D5",
|
||||
300: "#AFCCC8",
|
||||
400: "#9BBFBA",
|
||||
500: "#88B3AD",
|
||||
600: "#74A6A0",
|
||||
700: "#5F9A93",
|
||||
800: "#4A8E87",
|
||||
900: "#32827A",
|
||||
transparent: "#EBF2F1",
|
||||
},
|
||||
"#0C6760",
|
||||
);
|
||||
|
||||
const secondary = withHover(
|
||||
{
|
||||
DEFAULT: "#2563EB",
|
||||
100: "#D6E4FF",
|
||||
200: "#C2D7FE",
|
||||
300: "#AEC9FD",
|
||||
400: "#9ABCFB",
|
||||
500: "#87AEF9",
|
||||
600: "#74A0F7",
|
||||
700: "#6092F5",
|
||||
800: "#4D83F2",
|
||||
900: "#3973EE",
|
||||
transparent: "#EAF2FF",
|
||||
},
|
||||
"#1F56CF",
|
||||
);
|
||||
|
||||
const success = withHover(
|
||||
{
|
||||
DEFAULT: "#059669",
|
||||
100: "#DAECE2",
|
||||
200: "#C7E2D4",
|
||||
300: "#B4D9C6",
|
||||
400: "#A1CFB8",
|
||||
500: "#8EC6AB",
|
||||
600: "#7BBC9D",
|
||||
700: "#66B390",
|
||||
800: "#50A983",
|
||||
900: "#36A076",
|
||||
transparent: "#ECF5F1",
|
||||
},
|
||||
"#04845C",
|
||||
);
|
||||
|
||||
const warning = withHover(
|
||||
{
|
||||
DEFAULT: "#D97706",
|
||||
100: "#FAE7D9",
|
||||
200: "#F8DBC6",
|
||||
300: "#F5CFB2",
|
||||
400: "#F1C29F",
|
||||
500: "#EEB68C",
|
||||
600: "#EAAA78",
|
||||
700: "#E69D64",
|
||||
800: "#E2914D",
|
||||
900: "#DE8434",
|
||||
transparent: "#FDF3EC",
|
||||
},
|
||||
"#BF6805",
|
||||
);
|
||||
|
||||
const danger = withHover(
|
||||
{
|
||||
DEFAULT: "#B91C1C",
|
||||
100: "#F8D9D5",
|
||||
200: "#F3C6C0",
|
||||
300: "#EEB4AC",
|
||||
400: "#EBA198",
|
||||
500: "#E28E84",
|
||||
600: "#DB7B70",
|
||||
700: "#D3675C",
|
||||
800: "#CB5248",
|
||||
900: "#C23B33",
|
||||
transparent: "#FCECEA",
|
||||
},
|
||||
"#A31717",
|
||||
);
|
||||
|
||||
const info = withHover(
|
||||
{
|
||||
DEFAULT: "#0EA5E9",
|
||||
100: "#DCEFFC",
|
||||
200: "#CBE7FB",
|
||||
300: "#B9DFF9",
|
||||
400: "#A7D7F7",
|
||||
500: "#94CFF5",
|
||||
600: "#81C7F3",
|
||||
700: "#6DBFF1",
|
||||
800: "#57B6EE",
|
||||
900: "#3CAEEC",
|
||||
transparent: "#EEF7FE",
|
||||
},
|
||||
"#0B91CD",
|
||||
);
|
||||
|
||||
const gray = withHover(
|
||||
{
|
||||
100: "#D1D4D8",
|
||||
200: "#BBBFC5",
|
||||
300: "#A5AAB3",
|
||||
400: "#9096A1",
|
||||
500: "#7B838F",
|
||||
600: "#67707D",
|
||||
700: "#545D6C",
|
||||
800: "#414B5B",
|
||||
900: "#2F3A4B",
|
||||
transparent: "#E8E9EC",
|
||||
},
|
||||
"#192333",
|
||||
);
|
||||
|
||||
const light = withHover(
|
||||
{
|
||||
DEFAULT: "#F8F9FA",
|
||||
100: "#FAFBFD",
|
||||
200: "#F6F8FB",
|
||||
300: "#F3F4F6",
|
||||
400: "#F3F6F9",
|
||||
500: "#F1F4F7",
|
||||
600: "#EEF2F6",
|
||||
700: "#ECF1F6",
|
||||
800: "#E9EFF3",
|
||||
900: "#E7EDF2",
|
||||
transparent: "#FDFDFE",
|
||||
},
|
||||
"#DBDCDE",
|
||||
);
|
||||
|
||||
export const edenColors = {
|
||||
primary,
|
||||
secondary,
|
||||
success,
|
||||
warning,
|
||||
danger,
|
||||
info,
|
||||
gray,
|
||||
light,
|
||||
dark: "#1E293B",
|
||||
white: "#FFFFFF",
|
||||
black: "#000000",
|
||||
orange: { DEFAULT: "#E65100", transparent: "#FFF0EB" },
|
||||
purple: { DEFAULT: "#6A1B9A", transparent: "#F1EBF7" },
|
||||
pink: { DEFAULT: "#CC25B0", transparent: "#FDEDF8" },
|
||||
indigo: { DEFAULT: "#303F9F", transparent: "#EAEDF7" },
|
||||
cyan: { DEFAULT: "#0891B2", transparent: "#EBF3F6" },
|
||||
teal: { DEFAULT: "#0D9488", transparent: "#EBF2F1" },
|
||||
default: "#67707D",
|
||||
title: "#2F3A4B",
|
||||
"border-color": "#E8E9EC",
|
||||
skeleton: "#E4E7EB",
|
||||
} satisfies Record<string, unknown>;
|
||||
|
||||
/** Sidebar/topbar layout tokens (`--sidenav-width`, `--topbar-height`, …). */
|
||||
export const edenLayout = {
|
||||
sidebarWidth: "242px",
|
||||
sidebarMiniWidth: "56px",
|
||||
sidebarExpandMiniWidth: "80px",
|
||||
topbarHeight: "56px",
|
||||
};
|
||||
|
||||
const edenTheme: PresetTheme = {
|
||||
extend: {
|
||||
colors: edenColors,
|
||||
fontFamily: {
|
||||
// Theme source imports Google Fonts "Geist" as --font-family-primary.
|
||||
// (Older EDEN docs referenced Nunito/Poppins for a prior theme
|
||||
// revision — confirmed against the actual shipped tailwind/style.css
|
||||
// that this is the current value; update here if the theme changes.)
|
||||
sans: ["Geist", "ui-sans-serif", "system-ui", "-apple-system", "Segoe UI", "Roboto", "sans-serif"],
|
||||
},
|
||||
borderRadius: {
|
||||
xs: "2px",
|
||||
sm: "4px",
|
||||
md: "8px",
|
||||
xl: "12px",
|
||||
},
|
||||
boxShadow: {
|
||||
DEFAULT: "0px 1px 4px 0px rgba(0, 0, 0, 0.05)",
|
||||
sm: "0px 4px 4px 0px rgba(219, 219, 219, 0.25)",
|
||||
lg: "0px 4px 30px 0px rgba(20, 20, 20, 0.08)",
|
||||
primary: "0px 4px 10px 0px rgba(46, 55, 164, 0.1)",
|
||||
},
|
||||
spacing: {
|
||||
sidebar: edenLayout.sidebarWidth,
|
||||
"sidebar-mini": edenLayout.sidebarMiniWidth,
|
||||
topbar: edenLayout.topbarHeight,
|
||||
},
|
||||
backgroundImage: {
|
||||
"primary-gradient": "linear-gradient(90deg, #0F766E 0%, #059669 100%)",
|
||||
"dark-gradient": "linear-gradient(90deg, #1E293B 0%, #0F172A 100%)",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// A preset intentionally omits `content` (each consuming app supplies its
|
||||
// own) — Tailwind's `Config` type marks it required, so we type this as a
|
||||
// partial config rather than a full `Config`.
|
||||
const edenPreset: Partial<Config> = {
|
||||
theme: edenTheme,
|
||||
};
|
||||
|
||||
export default edenPreset;
|
||||
4
packages/ui/src/utils/cn.ts
Normal file
4
packages/ui/src/utils/cn.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/** Joins class name fragments, skipping falsy values. No `clsx` dependency needed for this. */
|
||||
export function cn(...parts: Array<string | false | null | undefined>): string {
|
||||
return parts.filter(Boolean).join(" ");
|
||||
}
|
||||
Reference in New Issue
Block a user