feat(proj): init

This commit is contained in:
vl.arkhangelskii
2026-09-21 04:06:43 +03:00
commit c956b94983
1076 changed files with 50876 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
export type AppButtonStyle = "filled" | "tinted" | "gray" | "plain" | "destructive";
export type AppButtonSize = "large" | "medium" | "small";
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
label?: ReactNode;
children?: ReactNode;
buttonStyle?: AppButtonStyle;
size?: AppButtonSize;
expanded?: boolean;
loading?: boolean;
};
export function AppButton({
label,
children,
buttonStyle = "filled",
size = "large",
expanded = true,
loading = false,
className = "",
disabled,
type = "button",
...rest
}: Props) {
const classes = [
"app-btn",
`app-btn--${buttonStyle}`,
`app-btn--${size}`,
expanded ? "app-btn--expanded" : "",
className,
]
.filter(Boolean)
.join(" ");
return (
<button type={type} className={classes} disabled={disabled || loading} {...rest}>
{loading ? <span className="app-spinner" aria-hidden="true" /> : (label ?? children)}
</button>
);
}
+96
View File
@@ -0,0 +1,96 @@
import type { ReactNode } from "react";
import { AppButton } from "./AppButton";
import { AppText } from "./AppText";
export function AppSpinner() {
return <span className="app-spinner" role="status" aria-label="Загрузка" />;
}
export function AppSkeleton({ width = "100%", height = 12 }: { width?: string | number; height?: number }) {
return <span className="app-skeleton" style={{ width, height }} />;
}
export function AppProgress({
spent,
total,
label,
}: {
spent: number;
total: number;
label?: string;
}) {
const pct = total > 0 ? Math.min(100, Math.max(0, (spent / total) * 100)) : 0;
const tone = pct >= 100 ? "is-danger" : pct >= 85 ? "is-warn" : "";
return (
<div className="app-progress">
<div
className="app-progress__track"
role="progressbar"
aria-valuenow={Math.round(pct)}
aria-valuemin={0}
aria-valuemax={100}
aria-label={label ?? "Потрачено от бюджета"}
>
<span className={`app-progress__bar ${tone}`.trim()} style={{ width: `${pct}%` }} />
</div>
</div>
);
}
export function AppEmptyView({
title = "Пусто",
message,
action,
}: {
title?: string;
message?: ReactNode;
action?: ReactNode;
}) {
return (
<div className="app-empty">
<AppText variant="headline">{title}</AppText>
{message ? <AppText variant="subhead">{message}</AppText> : null}
{action}
</div>
);
}
export function AppErrorView({
message,
onRetry,
}: {
message: string;
onRetry?: () => void;
}) {
return (
<div className="app-error">
<AppText variant="headline">Не удалось загрузить</AppText>
<AppText variant="subhead">{message}</AppText>
{onRetry ? (
<AppButton label="Повторить" buttonStyle="tinted" size="medium" expanded={false} onClick={onRetry} />
) : null}
</div>
);
}
export function AppChip({
label,
selected = false,
onClick,
disabled,
}: {
label: string;
selected?: boolean;
onClick?: () => void;
disabled?: boolean;
}) {
return (
<button type="button" className={`app-chip${selected ? " is-on" : ""}`} onClick={onClick} disabled={disabled}>
{label}
</button>
);
}
export function AppAvatar({ initials }: { initials: string }) {
return <span className="app-avatar">{initials}</span>;
}
+75
View File
@@ -0,0 +1,75 @@
import type { ReactNode } from "react";
import { AppSectionHeader } from "./AppText";
type SectionProps = {
header?: string;
footer?: ReactNode;
children: ReactNode;
flush?: boolean;
};
export function AppListSection({ header, footer, children, flush = false }: SectionProps) {
return (
<section>
{header ? <AppSectionHeader>{header}</AppSectionHeader> : null}
<div className={`app-list${flush ? " app-list--flush" : ""}`}>{children}</div>
{footer ? <p className="app-section-footer">{footer}</p> : null}
</section>
);
}
type TileProps = {
title: ReactNode;
subtitle?: ReactNode;
value?: ReactNode;
leading?: ReactNode;
trailing?: ReactNode;
chevron?: boolean;
destructive?: boolean;
onClick?: () => void;
href?: string;
};
export function AppListTile({
title,
subtitle,
value,
leading,
trailing,
chevron = false,
destructive = false,
onClick,
href,
}: TileProps) {
const interactive = Boolean(onClick || href);
const className = `app-tile${destructive ? " app-tile--destructive" : ""}`;
const body = (
<>
{leading}
<div className="app-tile__body">
<p className="app-tile__title">{title}</p>
{subtitle ? <p className="app-tile__subtitle">{subtitle}</p> : null}
</div>
{trailing ?? (value != null ? <p className="app-tile__value">{value}</p> : null)}
{chevron ? <span className="app-tile__chevron" aria-hidden="true"></span> : null}
</>
);
if (href) {
return (
<a className={className} href={href}>
{body}
</a>
);
}
if (interactive) {
return (
<button type="button" className={className} onClick={onClick}>
{body}
</button>
);
}
return <div className={className}>{body}</div>;
}
+32
View File
@@ -0,0 +1,32 @@
type Option<T extends string> = { value: T; label: string };
type Props<T extends string> = {
value: T;
options: Option<T>[];
onChange: (value: T) => void;
ariaLabel?: string;
};
export function AppSegmented<T extends string>({
value,
options,
onChange,
ariaLabel,
}: Props<T>) {
return (
<div className="app-segmented" role="tablist" aria-label={ariaLabel}>
{options.map((option) => (
<button
key={option.value}
type="button"
role="tab"
aria-selected={option.value === value}
className={`app-segmented__item${option.value === value ? " is-on" : ""}`}
onClick={() => onChange(option.value)}
>
{option.label}
</button>
))}
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
import type { ElementType, ReactNode } from "react";
export type AppTextVariant =
| "largeTitle"
| "title1"
| "title2"
| "title3"
| "headline"
| "body"
| "callout"
| "subhead"
| "footnote"
| "caption";
export type AppTextTone = "label" | "secondary" | "tertiary" | "accent" | "danger";
const DEFAULT_TONE: Record<AppTextVariant, AppTextTone> = {
largeTitle: "label",
title1: "label",
title2: "label",
title3: "label",
headline: "label",
body: "label",
callout: "secondary",
subhead: "secondary",
footnote: "secondary",
caption: "tertiary",
};
type Props = {
children: ReactNode;
variant?: AppTextVariant;
tone?: AppTextTone;
as?: ElementType;
className?: string;
};
export function AppText({
children,
variant = "body",
tone,
as: Tag = "p",
className = "",
}: Props) {
const color = tone ?? DEFAULT_TONE[variant];
return (
<Tag className={`app-text app-text--${variant} app-text--${color} ${className}`.trim()}>
{children}
</Tag>
);
}
export function AppSectionHeader({ children }: { children: ReactNode }) {
return (
<span className="app-section-header">
{typeof children === "string" ? children.toUpperCase() : children}
</span>
);
}
+54
View File
@@ -0,0 +1,54 @@
import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes } from "react";
import { AppSectionHeader } from "./AppText";
type FieldProps = {
label?: string;
hint?: ReactNode;
error?: string;
className?: string;
};
export function AppTextField({
label,
id,
hint,
error,
className = "",
...rest
}: InputHTMLAttributes<HTMLInputElement> & FieldProps & { id: string }) {
return (
<div className={`app-field ${error ? "app-field--error" : ""} ${className}`.trim()}>
{label ? (
<label htmlFor={id}>
<AppSectionHeader>{label}</AppSectionHeader>
</label>
) : null}
<input id={id} className="app-field__control" {...rest} />
{error ? <p className="app-field__error">{error}</p> : hint}
</div>
);
}
export function AppSelect({
label,
id,
hint,
error,
className = "",
children,
...rest
}: SelectHTMLAttributes<HTMLSelectElement> & FieldProps & { id: string }) {
return (
<div className={`app-field ${error ? "app-field--error" : ""} ${className}`.trim()}>
{label ? (
<label htmlFor={id}>
<AppSectionHeader>{label}</AppSectionHeader>
</label>
) : null}
<select id={id} className="app-field__control" {...rest}>
{children}
</select>
{error ? <p className="app-field__error">{error}</p> : hint}
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
export { AppText, AppSectionHeader } from "./AppText";
export { AppButton } from "./AppButton";
export type { AppButtonSize, AppButtonStyle } from "./AppButton";
export { AppTextField, AppSelect } from "./AppTextField";
export { AppListSection, AppListTile } from "./AppList";
export { AppSegmented } from "./AppSegmented";
export {
AppSpinner,
AppSkeleton,
AppProgress,
AppEmptyView,
AppErrorView,
AppChip,
AppAvatar,
} from "./AppFeedback";
+346
View File
@@ -0,0 +1,346 @@
/* iOS kit — mirrors mobile/lib/ui */
.app-text {
margin: 0;
font-family: var(--font-sans);
}
.app-text--largeTitle {
font-size: var(--text-large-title);
font-weight: 700;
letter-spacing: 0.37px;
line-height: 1.2;
}
.app-text--title1 {
font-size: var(--text-title1);
font-weight: 700;
letter-spacing: 0.36px;
line-height: 1.2;
}
.app-text--title2 {
font-size: var(--text-title2);
font-weight: 700;
letter-spacing: 0.35px;
line-height: 1.25;
}
.app-text--title3 {
font-size: var(--text-title3);
font-weight: 600;
letter-spacing: 0.38px;
line-height: 1.25;
}
.app-text--headline {
font-size: var(--text-headline);
font-weight: 600;
letter-spacing: -0.41px;
line-height: 1.3;
}
.app-text--body {
font-size: var(--text-body);
font-weight: 400;
letter-spacing: -0.41px;
line-height: 1.3;
}
.app-text--callout {
font-size: var(--text-callout);
font-weight: 400;
letter-spacing: -0.32px;
line-height: 1.3;
}
.app-text--subhead {
font-size: var(--text-subhead);
font-weight: 400;
letter-spacing: -0.24px;
line-height: 1.3;
}
.app-text--footnote {
font-size: var(--text-footnote);
font-weight: 400;
letter-spacing: -0.08px;
line-height: 1.3;
}
.app-text--caption {
font-size: var(--text-caption1);
font-weight: 400;
line-height: 1.3;
}
.app-text--label { color: var(--label); }
.app-text--secondary { color: var(--secondary-label); }
.app-text--tertiary { color: var(--tertiary-label); }
.app-text--accent { color: var(--accent); }
.app-text--danger { color: var(--system-red); }
.app-section-header {
display: block;
margin: 0;
padding: var(--space-4) var(--gutter) var(--space-2);
font-size: var(--text-footnote);
font-weight: 400;
letter-spacing: 0.4px;
text-transform: uppercase;
color: var(--secondary-label);
}
.app-section-footer {
margin: 0;
padding: var(--space-2) var(--gutter) 0;
font-size: var(--text-footnote);
color: var(--secondary-label);
}
.app-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
border: 0;
cursor: pointer;
font-family: var(--font-sans);
font-weight: 600;
letter-spacing: -0.41px;
text-decoration: none;
color: inherit;
transition: filter 140ms ease, opacity 140ms ease;
}
.app-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.app-btn--expanded { width: 100%; }
.app-btn--large {
height: var(--control-height);
padding: 0 var(--space-4);
border-radius: var(--radius-lg);
font-size: var(--text-headline);
}
.app-btn--medium {
height: var(--control-height-md);
padding: 0 var(--space-4);
border-radius: var(--radius-lg);
font-size: var(--text-body);
}
.app-btn--small {
height: var(--control-height-sm);
padding: 0 var(--space-3);
border-radius: var(--radius-md);
font-size: var(--text-subhead);
}
.app-btn--filled { background: var(--accent); color: #fff; }
.app-btn--tinted { background: rgb(18 136 90 / 15%); color: var(--accent); }
.app-btn--gray { background: var(--system-gray5); color: var(--label); }
.app-btn--plain { background: transparent; color: var(--accent); }
.app-btn--destructive { background: rgb(255 59 48 / 15%); color: var(--system-red); }
.app-btn:hover:not(:disabled) { filter: brightness(1.04); }
.app-field { display: grid; gap: var(--space-2); }
.app-field .app-section-header {
padding: 0;
}
.app-field__control {
width: 100%;
height: var(--control-height-md);
border: 0;
border-radius: var(--radius-md);
background: var(--grouped-surface);
color: var(--label);
padding: 0 var(--space-3);
font-size: var(--text-body);
letter-spacing: -0.41px;
}
.app-field__control:disabled {
opacity: 0.4;
}
.app-field--error .app-field__control {
box-shadow: inset 0 0 0 1px var(--system-red);
}
.app-field__error {
margin: 0;
font-size: var(--text-footnote);
color: var(--system-red);
}
.app-list {
margin: 0 var(--gutter);
padding: 0;
list-style: none;
background: var(--grouped-surface);
border-radius: var(--radius-lg);
overflow: hidden;
}
.app-list--flush {
margin-inline: 0;
}
.app-tile {
display: flex;
align-items: center;
gap: var(--space-3);
width: 100%;
min-height: var(--row-min-height);
padding: 10px var(--gutter);
border: 0;
background: var(--grouped-surface);
color: inherit;
text-align: left;
font: inherit;
cursor: default;
}
button.app-tile,
a.app-tile {
cursor: pointer;
}
button.app-tile:hover,
a.app-tile:hover {
background: var(--system-gray5);
}
.app-tile + .app-tile {
box-shadow: inset 0 var(--hairline) 0 var(--separator);
background-clip: padding-box;
}
.app-tile__body { flex: 1; min-width: 0; }
.app-tile__title {
margin: 0;
font-size: var(--text-body);
letter-spacing: -0.41px;
color: var(--label);
}
.app-tile--destructive .app-tile__title { color: var(--system-red); }
.app-tile__subtitle {
margin: 2px 0 0;
font-size: var(--text-footnote);
color: var(--secondary-label);
}
.app-tile__value {
margin: 0;
font-size: var(--text-body);
color: var(--secondary-label);
font-variant-numeric: tabular-nums;
}
.app-tile__chevron {
color: var(--tertiary-label);
font-size: 17px;
line-height: 1;
}
.app-chip {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 28px;
padding: 0 12px;
border: 0;
border-radius: var(--radius-capsule);
background: var(--system-gray5);
color: var(--label);
font-size: var(--text-subhead);
cursor: pointer;
}
.app-chip.is-on {
background: rgb(18 136 90 / 15%);
color: var(--accent);
}
.app-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: var(--radius-capsule);
background: var(--accent-soft);
color: var(--accent);
font-size: var(--text-subhead);
font-weight: 600;
}
.app-progress__track {
height: 4px;
border-radius: var(--radius-capsule);
background: var(--system-gray5);
overflow: hidden;
}
.app-progress__bar {
display: block;
height: 100%;
background: var(--accent);
border-radius: inherit;
}
.app-progress__bar.is-warn { background: var(--system-orange); }
.app-progress__bar.is-danger { background: var(--system-red); }
.app-spinner {
width: 20px;
height: 20px;
border: 2px solid var(--system-gray3);
border-top-color: var(--secondary-label);
border-radius: 50%;
animation: app-spin 700ms linear infinite;
}
@keyframes app-spin { to { transform: rotate(360deg); } }
.app-skeleton {
display: block;
height: 12px;
border-radius: var(--radius-sm);
background: var(--system-gray5);
}
.app-empty,
.app-error {
display: grid;
justify-items: center;
gap: var(--space-3);
padding: var(--space-7) var(--gutter);
text-align: center;
color: var(--secondary-label);
}
.app-segmented {
display: flex;
padding: 2px;
border-radius: 9px;
background: var(--system-gray5);
}
.app-segmented__item {
flex: 1;
min-height: 32px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--label);
font-size: var(--text-subhead);
font-weight: 500;
cursor: pointer;
}
.app-segmented__item.is-on {
background: var(--grouped-surface);
box-shadow: 0 1px 3px rgb(0 0 0 / 12%);
}
.app-toast {
position: fixed;
left: 50%;
bottom: 24px;
transform: translateX(-50%);
z-index: var(--z-toast);
display: flex;
align-items: center;
gap: var(--space-2);
min-height: 44px;
padding: 0 16px;
border-radius: var(--radius-capsule);
background: rgb(28 28 30 / 88%);
color: #fff;
font-size: var(--text-subhead);
backdrop-filter: blur(16px);
}
@media (prefers-color-scheme: dark) {
.app-btn--tinted { background: rgb(60 214 140 / 18%); }
}