first commit

This commit is contained in:
2025-12-27 18:23:41 +03:00
commit 63a9425680
197 changed files with 7078 additions and 0 deletions

View File

@@ -0,0 +1,44 @@
import React from "react";
// import "./checkable.css";
export function Checkbox(props: {
checked: boolean;
onChange: (checked: boolean) => void;
label?: React.ReactNode;
description?: React.ReactNode;
disabled?: boolean;
}) {
const { checked, onChange, label, description, disabled } = props;
return (
<div
className="ui-check"
data-checked={checked ? "true" : "false"}
aria-disabled={disabled ? "true" : "false"}
onClick={() => {
if (disabled) return;
onChange(!checked);
}}
role="checkbox"
aria-checked={checked}
tabIndex={0}
onKeyDown={(e) => {
if (disabled) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onChange(!checked);
}
}}
>
<span className="ui-check__box" aria-hidden>
<span className="ui-check__mark" />
</span>
<span>
{label ? <div className="ui-check__label">{label}</div> : null}
{description ? <div className="ui-check__desc">{description}</div> : null}
</span>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import React from "react";
// import "./chips.css";
export function Chip(props: { label: string; onRemove?: () => void }) {
return (
<span className="ui-chip">
<span>{props.label}</span>
{props.onRemove ? (
<button type="button" className="ui-chip__x" onClick={props.onRemove} aria-label="Remove">
×
</button>
) : null}
</span>
);
}

View File

@@ -0,0 +1,56 @@
import React from "react";
import { Chip } from "./Chip";
// import "./chips.css";
export function ChipsInput(props: {
value: string[];
onChange: (value: string[]) => void;
placeholder?: string;
label?: React.ReactNode;
}) {
const { value, onChange, placeholder = "Введите и нажмите Enter", label } = props;
const [text, setText] = React.useState("");
const add = () => {
const v = text.trim();
if (!v) return;
if (value.includes(v)) {
setText("");
return;
}
onChange([...value, v]);
setText("");
};
return (
<div style={{ display: "grid", gap: 8 }}>
{label ? <div style={{ fontWeight: 700, fontSize: 13 }}>{label}</div> : null}
<div className="ui-chipsbox">
<div className="ui-chiprow">
{value.map((v) => (
<Chip key={v} label={v} onRemove={() => onChange(value.filter((x) => x !== v))} />
))}
<input
className="ui-chipsinput__input"
value={text}
placeholder={placeholder}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
add();
}
if (e.key === "Backspace" && text.length === 0 && value.length > 0) {
onChange(value.slice(0, -1));
}
}}
/>
</div>
<div style={{ color: "var(--ui-muted)", fontSize: 12 }}>
Подсказка: Enter добавить, Backspace удалить последний.
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import React from "react";
import { Chip } from "./Chip";
// import "./chips.css";
import { SelectMimicry, type MimicOption } from "./SelectMimicry";
export function ChipsSelect(props: {
value: string[];
onChange: (value: string[]) => void;
options: MimicOption[];
label?: React.ReactNode;
}) {
const { value, onChange, options, label } = props;
const available = options.filter((o) => !value.includes(o.key));
return (
<div style={{ display: "grid", gap: 8 }}>
{label ? <div style={{ fontWeight: 700, fontSize: 13 }}>{label}</div> : null}
<div className="ui-chipsbox">
<div className="ui-chiprow">
{value.map((k) => (
<Chip
key={k}
label={options.find((o) => o.key === k)?.label ?? k}
onRemove={() => onChange(value.filter((x) => x !== k))}
/>
))}
</div>
<SelectMimicry
valueKey={undefined}
onChange={(k) => onChange([...value, k])}
options={available}
placeholder={available.length ? "Добавить..." : "Все добавлено"}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,44 @@
import React from "react";
import * as RadixSelect from "@radix-ui/react-select";
// import "./custom-select.css";
export type SelectOption = { value: string; label: string; disabled?: boolean };
export function CustomSelect(props: {
value: string;
onChange: (value: string) => void;
options: SelectOption[];
placeholder?: string;
label?: React.ReactNode;
hint?: React.ReactNode;
error?: React.ReactNode;
}) {
const { value, onChange, options, placeholder, label, hint, error } = props;
return (
<div style={{ display: "grid", gap: 8 }}>
{label ? <div style={{ fontWeight: 700, fontSize: 13 }}>{label}</div> : null}
<RadixSelect.Root value={value} onValueChange={onChange}>
<RadixSelect.Trigger className="ui-customselect__trigger">
<RadixSelect.Value placeholder={placeholder ?? "Выберите..."} />
</RadixSelect.Trigger>
<RadixSelect.Portal>
<RadixSelect.Content className="ui-customselect__content" position="popper" sideOffset={8}>
<RadixSelect.Viewport>
{options.map((o) => (
<RadixSelect.Item key={o.value} value={o.value} disabled={o.disabled} className="ui-customselect__item">
<RadixSelect.ItemText>{o.label}</RadixSelect.ItemText>
</RadixSelect.Item>
))}
</RadixSelect.Viewport>
</RadixSelect.Content>
</RadixSelect.Portal>
</RadixSelect.Root>
{error ? <div className="ui-control__error">{error}</div> : hint ? <div className="ui-control__hint">{hint}</div> : null}
</div>
);
}

View File

@@ -0,0 +1,56 @@
import React from "react";
// import "./dropzone.css";
export function DropZone(props: {
onFiles: (files: FileList) => void;
title?: React.ReactNode;
hint?: React.ReactNode;
}) {
const { onFiles, title = "Перетащите файлы сюда", hint = "или нажмите, чтобы выбрать" } = props;
const [over, setOver] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement | null>(null);
return (
<div
className="ui-drop"
data-over={over ? "true" : "false"}
onDragEnter={(e) => {
e.preventDefault();
setOver(true);
}}
onDragOver={(e) => {
e.preventDefault();
setOver(true);
}}
onDragLeave={(e) => {
e.preventDefault();
setOver(false);
}}
onDrop={(e) => {
e.preventDefault();
setOver(false);
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
onFiles(e.dataTransfer.files);
}
}}
onClick={() => inputRef.current?.click()}
role="button"
tabIndex={0}
>
<input
ref={inputRef}
type="file"
style={{ display: "none" }}
onChange={(e) => {
const files = e.target.files;
if (!files || files.length === 0) return;
onFiles(files);
}}
/>
<div className="ui-drop__title">{title}</div>
<div className="ui-drop__hint">{hint}</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import React from "react";
// import "./file.css";
export function File(props: {
label?: React.ReactNode;
accept?: string;
multiple?: boolean;
onFiles: (files: FileList) => void;
}) {
const { label, accept, multiple, onFiles } = props;
const inputRef = React.useRef<HTMLInputElement | null>(null);
const [name, setName] = React.useState<string>("");
return (
<div className="ui-file">
{label ? <div className="ui-file__label">{label}</div> : null}
<input
ref={inputRef}
type="file"
accept={accept}
multiple={multiple}
style={{ display: "none" }}
onChange={(e) => {
const files = e.target.files;
if (!files || files.length === 0) return;
setName(multiple ? `${files.length} файлов` : files[0].name);
onFiles(files);
}}
/>
<button
type="button"
className="ui-file__button"
onClick={() => inputRef.current?.click()}
>
<span>Выбрать файл</span>
<span className="ui-file__name">{name || "Не выбрано"}</span>
</button>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import React from "react";
export function FormField(props: React.PropsWithChildren<{ label?: React.ReactNode; hint?: React.ReactNode; error?: React.ReactNode }>) {
const { label, hint, error, children } = props;
return (
<div style={{ display: "grid", gap: 8 }}>
{label ? <div style={{ fontWeight: 700, fontSize: 13 }}>{label}</div> : null}
{children}
{error ? (
<div style={{ color: "color-mix(in oklab, var(--ui-danger) 85%, white)", fontSize: 12 }}>{error}</div>
) : hint ? (
<div style={{ color: "var(--ui-muted)", fontSize: 12 }}>{hint}</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,5 @@
import React from "react";
export function FormItem(props: React.PropsWithChildren) {
return <div style={{ display: "grid", gap: 12 }}>{props.children}</div>;
}

View File

@@ -0,0 +1,12 @@
import React from "react";
import { Alert } from "../feedback/Alert";
export function FormStatus(props: { type: "info" | "success" | "warning" | "error"; title: string; description?: string }) {
const { type, title, description } = props;
const variant =
type === "success" ? "success" :
type === "error" ? "danger" :
type === "warning" ? "warning" : "neutral";
return <Alert variant={variant} title={title} description={description} />;
}

View File

@@ -0,0 +1,43 @@
import React from "react";
import clsx from "clsx";
// import "../_shared/control.css";
// import "./input.css";
export type InputProps = React.InputHTMLAttributes<HTMLInputElement> & {
label?: React.ReactNode;
hint?: React.ReactNode;
error?: React.ReactNode;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
};
export function Input(props: InputProps) {
const { label, hint, error, leftIcon, rightIcon, className, ...rest } = props;
const hasLeft = Boolean(leftIcon);
const hasRight = Boolean(rightIcon);
return (
<div className="ui-input__wrap">
{label ? <div className="ui-input__label">{label}</div> : null}
<div className="ui-input__fieldWrap">
{hasLeft ? <span className="ui-input__iconLeft">{leftIcon}</span> : null}
{hasRight ? <span className="ui-input__iconRight">{rightIcon}</span> : null}
<input
className={clsx(
"ui-control",
hasLeft && "ui-input__withLeft",
hasRight && "ui-input__withRight",
className
)}
{...rest}
/>
</div>
{error ? <div className="ui-control__error">{error}</div> : hint ? <div className="ui-control__hint">{hint}</div> : null}
</div>
);
}

View File

@@ -0,0 +1,23 @@
import React from "react";
import clsx from "clsx";
// import "../_shared/control.css";
export function NativeSelect(
props: React.SelectHTMLAttributes<HTMLSelectElement> & {
label?: React.ReactNode;
hint?: React.ReactNode;
error?: React.ReactNode;
}
) {
const { label, hint, error, className, children, ...rest } = props;
return (
<div style={{ display: "grid", gap: 8 }}>
{label ? <div style={{ fontWeight: 700, fontSize: 13 }}>{label}</div> : null}
<select className={clsx("ui-control", className)} {...rest}>
{children}
</select>
{error ? <div className="ui-control__error">{error}</div> : hint ? <div className="ui-control__hint">{hint}</div> : null}
</div>
);
}

View File

@@ -0,0 +1,44 @@
import React from "react";
// import "./checkable.css";
export function Radio(props: {
checked: boolean;
onChange: () => void;
label?: React.ReactNode;
description?: React.ReactNode;
disabled?: boolean;
}) {
const { checked, onChange, label, description, disabled } = props;
return (
<div
className="ui-check"
data-checked={checked ? "true" : "false"}
aria-disabled={disabled ? "true" : "false"}
onClick={() => {
if (disabled) return;
onChange();
}}
role="radio"
aria-checked={checked}
tabIndex={0}
onKeyDown={(e) => {
if (disabled) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onChange();
}
}}
>
<span className="ui-check__box" aria-hidden style={{ borderRadius: 999 }}>
<span className="ui-check__mark" style={{ borderRadius: 999 }} />
</span>
<span>
{label ? <div className="ui-check__label">{label}</div> : null}
{description ? <div className="ui-check__desc">{description}</div> : null}
</span>
</div>
);
}

View File

@@ -0,0 +1,32 @@
import React from "react";
// import "./segmented.css";
export type SegmentedItem = { key: string; label: React.ReactNode; disabled?: boolean };
export function SegmentedControl(props: {
items: SegmentedItem[];
value: string;
onChange: (key: string) => void;
}) {
const { items, value, onChange } = props;
return (
<div className="ui-segmented" role="tablist" aria-label="Segmented control">
{items.map((it) => {
const active = it.key === value;
return (
<button
key={it.key}
type="button"
className="ui-segmented__btn"
data-active={active ? "true" : "false"}
disabled={it.disabled}
onClick={() => !it.disabled && onChange(it.key)}
>
{it.label}
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,55 @@
import React from "react";
import * as Popover from "@radix-ui/react-popover";
import clsx from "clsx";
// import "../_shared/control.css";
// import "./select-mimicry.css";
export type MimicOption = { key: string; label: string; description?: string; disabled?: boolean };
export function SelectMimicry(props: {
valueKey?: string;
onChange: (key: string) => void;
options: MimicOption[];
placeholder?: string;
label?: React.ReactNode;
className?: string;
}) {
const { valueKey, onChange, options, placeholder = "Выберите...", label, className } = props;
const selected = options.find((o) => o.key === valueKey);
return (
<div style={{ display: "grid", gap: 8 }}>
{label ? <div style={{ fontWeight: 700, fontSize: 13 }}>{label}</div> : null}
<Popover.Root>
<Popover.Trigger asChild>
<button type="button" className={clsx("ui-control", "ui-mimic__trigger", className)}>
<span style={{ opacity: selected ? 1 : 0.7 }}>{selected?.label ?? placeholder}</span>
<span className="ui-mimic__chev"></span>
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content className="ui-mimic__content" sideOffset={8} align="start">
{options.map((o) => (
<button
key={o.key}
type="button"
className="ui-mimic__item"
disabled={o.disabled}
onClick={() => {
if (o.disabled) return;
onChange(o.key);
}}
>
<div style={{ fontWeight: 700, fontSize: 13 }}>{o.label}</div>
{o.description ? <div style={{ color: "var(--ui-muted)", fontSize: 12, marginTop: 2 }}>{o.description}</div> : null}
</button>
))}
</Popover.Content>
</Popover.Portal>
</Popover.Root>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import React from "react";
// import "./slider.css";
export function Slider(props: {
value: number;
onChange: (v: number) => void;
min?: number;
max?: number;
step?: number;
label?: React.ReactNode;
formatValue?: (v: number) => string;
}) {
const { value, onChange, min = 0, max = 100, step = 1, label, formatValue } = props;
return (
<div className="ui-slider">
<div className="ui-slider__row">
{label ? <div className="ui-slider__label">{label}</div> : <div />}
<div className="ui-slider__value">{(formatValue ?? String)(value)}</div>
</div>
<input
type="range"
value={value}
min={min}
max={max}
step={step}
onChange={(e) => onChange(Number(e.target.value))}
/>
</div>
);
}

View File

@@ -0,0 +1,39 @@
import React from "react";
// import "./switch.css";
export function Switch(props: {
checked: boolean;
onChange: (checked: boolean) => void;
label?: React.ReactNode;
disabled?: boolean;
}) {
const { checked, onChange, label, disabled } = props;
return (
<div
className="ui-switch"
data-checked={checked ? "true" : "false"}
aria-disabled={disabled ? "true" : "false"}
onClick={() => {
if (disabled) return;
onChange(!checked);
}}
role="switch"
aria-checked={checked}
tabIndex={0}
onKeyDown={(e) => {
if (disabled) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onChange(!checked);
}
}}
>
<span className="ui-switch__track" aria-hidden>
<span className="ui-switch__thumb" />
</span>
{label ? <span className="ui-switch__label">{label}</span> : null}
</div>
);
}

View File

@@ -0,0 +1,26 @@
import React from "react";
import clsx from "clsx";
// import "../_shared/control.css";
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement> & {
label?: React.ReactNode;
hint?: React.ReactNode;
error?: React.ReactNode;
};
export function Textarea(props: TextareaProps) {
const { label, hint, error, className, rows = 4, ...rest } = props;
return (
<div style={{ display: "grid", gap: 8 }}>
{label ? <div style={{ fontWeight: 700, fontSize: 13 }}>{label}</div> : null}
<textarea
className={clsx("ui-control", className)}
rows={rows}
style={{ height: "auto", paddingTop: 10, paddingBottom: 10, resize: "vertical" }}
{...rest}
/>
{error ? <div className="ui-control__error">{error}</div> : hint ? <div className="ui-control__hint">{hint}</div> : null}
</div>
);
}

View File

@@ -0,0 +1,38 @@
import React from "react";
import { Button } from "../button/Button";
// import "./writebar.css";
export function WriteBar(props: {
value: string;
onChange: (v: string) => void;
onSend: () => void;
placeholder?: string;
disabled?: boolean;
}) {
const { value, onChange, onSend, placeholder = "Сообщение...", disabled } = props;
return (
<div className="ui-writebar">
<div className="ui-writebar__inner">
<textarea
className="ui-writebar__input"
value={value}
placeholder={placeholder}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
rows={1}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (!disabled) onSend();
}
}}
/>
<Button variant="solid" tone="primary" disabled={disabled || value.trim().length === 0} onClick={onSend}>
Отправить
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,52 @@
.ui-check{
display: inline-flex;
align-items: center;
gap: 10px;
cursor: pointer;
user-select: none;
}
.ui-check input{ display: none; }
.ui-check__box{
width: 20px;
height: 20px;
border-radius: 6px;
border: 1px solid var(--ui-border);
background: var(--ui-panel);
display: inline-flex;
align-items: center;
justify-content: center;
transition: background .12s ease, border-color .12s ease;
}
.ui-check[data-checked="true"] .ui-check__box{
background: var(--ui-primary);
border-color: color-mix(in oklab, var(--ui-primary) 70%, var(--ui-border));
}
.ui-check__mark{
width: 10px;
height: 10px;
border-radius: 3px;
background: white;
opacity: 0;
transform: scale(.85);
transition: opacity .12s ease, transform .12s ease;
}
.ui-check[data-checked="true"] .ui-check__mark{
opacity: 1;
transform: scale(1);
}
.ui-check__label{
color: var(--ui-fg);
font-size: 13px;
}
.ui-check__desc{
color: var(--ui-muted);
font-size: 12px;
margin-top: 2px;
}
.ui-check[aria-disabled="true"]{
opacity: .6;
cursor: not-allowed;
}

View File

@@ -0,0 +1,49 @@
.ui-chiprow{
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.ui-chip{
display: inline-flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
border-radius: 999px;
border: 1px solid var(--ui-border);
background: rgba(255,255,255,0.04);
color: var(--ui-fg);
font-size: 13px;
}
:root[data-theme="light"] .ui-chip{
background: rgba(17,19,24,0.04);
}
.ui-chip__x{
border: 0;
background: transparent;
color: var(--ui-muted);
cursor: pointer;
padding: 0;
line-height: 1;
}
.ui-chipsinput__input{
height: 32px;
min-width: 120px;
border: 0;
outline: none;
background: transparent;
color: var(--ui-fg);
font: inherit;
}
.ui-chipsbox{
border: 1px solid var(--ui-border);
border-radius: var(--ui-radius);
background: var(--ui-panel);
padding: 10px;
display: grid;
gap: 8px;
}

View File

@@ -0,0 +1,36 @@
@import "../_shared/control.css";
.ui-customselect__trigger{
height: 40px;
padding: 0 12px;
border-radius: var(--ui-radius);
border: 1px solid var(--ui-border);
background: var(--ui-panel);
color: var(--ui-fg);
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 10px;
width: 100%;
}
.ui-customselect__content{
background: var(--ui-panel);
border: 1px solid var(--ui-border);
border-radius: var(--ui-radius);
box-shadow: var(--ui-shadow);
overflow: hidden;
min-width: 220px;
}
.ui-customselect__item{
padding: 10px 12px;
cursor: pointer;
}
.ui-customselect__item[data-highlighted]{
outline: none;
background: rgba(255,255,255,0.06);
}
:root[data-theme="light"] .ui-customselect__item[data-highlighted]{
background: rgba(17,19,24,0.06);
}

View File

@@ -0,0 +1,23 @@
.ui-drop{
border: 1px dashed var(--ui-border);
border-radius: var(--ui-radius);
background: rgba(255,255,255,0.02);
padding: 14px;
display: grid;
gap: 6px;
text-align: center;
}
:root[data-theme="light"] .ui-drop{
background: rgba(17,19,24,0.02);
}
.ui-drop[data-over="true"]{
border-color: color-mix(in oklab, var(--ui-primary) 55%, var(--ui-border));
box-shadow: var(--ui-focus);
}
.ui-drop__title{
font-weight: 800;
}
.ui-drop__hint{
color: var(--ui-muted);
font-size: 12px;
}

View File

@@ -0,0 +1,24 @@
.ui-file{
display: grid;
gap: 8px;
}
.ui-file__label{
font-weight: 700;
font-size: 13px;
}
.ui-file__button{
height: 40px;
padding: 0 12px;
border-radius: var(--ui-radius);
border: 1px dashed var(--ui-border);
background: transparent;
color: var(--ui-fg);
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
}
.ui-file__name{
color: var(--ui-muted);
font-size: 12px;
}

View File

@@ -0,0 +1,38 @@
@import "../_shared/control.css";
.ui-input{
}
.ui-input__wrap{
display: grid;
gap: 8px;
}
.ui-input__label{
font-weight: 700;
font-size: 13px;
}
.ui-input__fieldWrap{
position: relative;
}
.ui-input__iconLeft, .ui-input__iconRight{
position: absolute;
top: 50%;
transform: translateY(-50%);
color: var(--ui-muted);
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
pointer-events: none;
}
.ui-input__iconLeft{ left: 8px; }
.ui-input__iconRight{ right: 8px; }
.ui-input__withLeft{ padding-left: 42px; }
.ui-input__withRight{ padding-right: 42px; }

View File

@@ -0,0 +1,24 @@
.ui-segmented{
display: inline-flex;
border: 1px solid var(--ui-border);
border-radius: calc(var(--ui-radius) + 6px);
background: var(--ui-panel);
padding: 4px;
gap: 4px;
}
.ui-segmented__btn{
height: 34px;
padding: 0 12px;
border-radius: var(--ui-radius);
border: 0;
background: transparent;
color: var(--ui-muted);
cursor: pointer;
}
.ui-segmented__btn[data-active="true"]{
background: rgba(255,255,255,0.08);
color: var(--ui-fg);
}
:root[data-theme="light"] .ui-segmented__btn[data-active="true"]{
background: rgba(17,19,24,0.08);
}

View File

@@ -0,0 +1,40 @@
.ui-mimic__trigger{
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
}
.ui-mimic__chev{
color: var(--ui-muted);
}
.ui-mimic__content{
width: min(520px, calc(100vw - 24px));
background: var(--ui-panel);
border: 1px solid var(--ui-border);
border-radius: var(--ui-radius);
box-shadow: var(--ui-shadow);
padding: 6px;
}
.ui-mimic__item{
width: 100%;
border: 0;
background: transparent;
color: var(--ui-fg);
text-align: left;
padding: 10px 10px;
border-radius: calc(var(--ui-radius) - 2px);
cursor: pointer;
}
.ui-mimic__item:hover{
background: rgba(255,255,255,0.06);
}
:root[data-theme="light"] .ui-mimic__item:hover{
background: rgba(17,19,24,0.06);
}
.ui-mimic__item:disabled{
opacity: .55;
cursor: not-allowed;
}

View File

@@ -0,0 +1,22 @@
.ui-slider{
display: grid;
gap: 8px;
}
.ui-slider__row{
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.ui-slider__label{
font-weight: 700;
font-size: 13px;
}
.ui-slider__value{
color: var(--ui-muted);
font-size: 12px;
}
.ui-slider input[type="range"]{
width: 100%;
accent-color: var(--ui-primary);
}

View File

@@ -0,0 +1,46 @@
.ui-switch{
display: inline-flex;
align-items: center;
gap: 12px;
cursor: pointer;
user-select: none;
}
.ui-switch__track{
width: 46px;
height: 26px;
border-radius: 999px;
background: rgba(255,255,255,0.10);
border: 1px solid var(--ui-border);
position: relative;
transition: background .12s ease, border-color .12s ease;
}
:root[data-theme="light"] .ui-switch__track{
background: rgba(17,19,24,0.08);
}
.ui-switch[data-checked="true"] .ui-switch__track{
background: color-mix(in oklab, var(--ui-primary) 75%, black);
border-color: color-mix(in oklab, var(--ui-primary) 60%, var(--ui-border));
}
.ui-switch__thumb{
width: 22px;
height: 22px;
border-radius: 999px;
background: var(--ui-panel);
position: absolute;
top: 50%;
transform: translateY(-50%);
left: 2px;
transition: left .12s ease;
box-shadow: 0 6px 20px rgba(0,0,0,.18);
}
.ui-switch[data-checked="true"] .ui-switch__thumb{
left: 22px;
}
.ui-switch__label{
font-size: 13px;
color: var(--ui-fg);
}
.ui-switch[aria-disabled="true"]{
opacity: .6;
cursor: not-allowed;
}

View File

@@ -0,0 +1,30 @@
.ui-writebar{
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding-bottom: var(--safe-bottom);
background: color-mix(in oklab, var(--ui-bg) 92%, transparent);
backdrop-filter: blur(10px);
border-top: 1px solid var(--ui-border);
z-index: 70;
}
.ui-writebar__inner{
display: flex;
gap: 10px;
align-items: flex-end;
padding: 10px 12px;
}
.ui-writebar__input{
flex: 1;
min-height: 40px;
max-height: 120px;
height: auto;
padding: 10px 12px;
border-radius: var(--ui-radius);
border: 1px solid var(--ui-border);
background: var(--ui-panel);
color: var(--ui-fg);
outline: none;
resize: none;
}