Переделаны истории

This commit is contained in:
Халимов Рустам
2026-04-03 01:03:32 +03:00
parent 943699139b
commit 1b8abbc995
14 changed files with 1459 additions and 656 deletions

View File

@@ -22,15 +22,15 @@ internal sealed class FriendshipRepository : IFriendshipRepository
public async Task<List<Knot.Contracts.Relations.Domain.Friendship>> GetAcceptedFriendshipsAsync(Guid userId, CancellationToken ct = default)
{
var friendships = await _context.Set<Knot.Modules.Relations.Domain.Friendship>()
.Where(f => f.Status == Knot.Contracts.Relations.Domain.FriendshipStatus.Accepted && (f.UserId == userId || f.FriendId == userId))
var contacts = await _context.Contacts
.Where(f => f.Status == ContactStatus.Accepted && (f.UserId == userId || f.ContactId == userId))
.ToListAsync(ct);
return friendships.Select(f => new Knot.Contracts.Relations.Domain.Friendship(
return contacts.Select(f => new Knot.Contracts.Relations.Domain.Friendship(
f.Id,
f.UserId,
f.FriendId,
f.Status,
f.ContactId,
(Knot.Contracts.Relations.Domain.FriendshipStatus)f.Status,
f.CreatedAt
)).ToList();
}

View File

@@ -16,6 +16,7 @@ public static class DependencyInjection
{
services.AddScoped<IStoryRepository, StoryRepository>();
services.AddScoped<IUserRepository, UserRepository>();
services.AddScoped<IStoryNotificationService, StoryNotificationService>();
services.AddScoped<Knot.Contracts.Stories.Infrastructure.Persistence.IStoryCollection, StoryCollection>();
var connectionString = configuration.GetConnectionString("DefaultConnection");

View File

@@ -1,3 +1,7 @@
using Knot.Modules.Stories.Application.Stories.Commands.AddStoryReaction;
using Knot.Modules.Stories.Application.Stories.Commands.AddStoryReply;
using Knot.Modules.Stories.Application.Stories.Commands.RemoveStoryReaction;
using Knot.Modules.Stories.Application.Stories.Queries.GetStoryReplies;
using Knot.Modules.Stories.Application.DTOs;
using Knot.Modules.Stories.Application.Stories.Commands.CreateStory;
using Knot.Modules.Stories.Application.Stories.Commands.DeleteStory;
@@ -15,6 +19,9 @@ using Microsoft.AspNetCore.Routing;
namespace Knot.Modules.Stories.Presentation.Endpoints;
public record AddReactionRequest(string Emoji);
public record AddReplyRequest(string Content);
public static class StoriesEndpoints
{
public static void MapStoriesEndpoints(this WebApplication app)
@@ -64,6 +71,30 @@ public static class StoriesEndpoints
return Results.Ok(result.Value);
});
group.MapPost("{id:guid}/reaction", async ([FromRoute] Guid id, [FromBody] AddReactionRequest req, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new AddStoryReactionCommand(userContext.UserId, id, req.Emoji), ct);
return result.IsSuccess ? Results.Ok(new { message = "Reaction added" }) : Results.NotFound();
});
group.MapPost("{id:guid}/reaction/delete", async ([FromRoute] Guid id, [FromQuery] string emoji, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new RemoveStoryReactionCommand(userContext.UserId, id, emoji), ct);
return result.IsSuccess ? Results.Ok(new { message = "Reaction removed" }) : Results.NotFound();
});
group.MapPost("{id:guid}/reply", async ([FromRoute] Guid id, [FromBody] AddReplyRequest req, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new AddStoryReplyCommand(userContext.UserId, id, req.Content), ct);
return result.IsSuccess ? Results.Ok(new { message = "Reply added" }) : Results.NotFound();
});
group.MapGet("{id:guid}/replies", async ([FromRoute] Guid id, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new GetStoryRepliesQuery(userContext.UserId, id), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound();
});
group.MapDelete("{id:guid}", async ([FromRoute] Guid id, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new DeleteStoryCommand(userContext.UserId, id), ct);

View File

@@ -146,6 +146,24 @@ const translations = {
pinChat: 'Закрепить чат',
unpinChat: 'Открепить чат',
chatCleared: 'Чат очищен',
typeYourStoryPlaceholder: 'Напишите историю...',
uploadMedia: 'Загрузить медиа',
chooseBackground: 'Цвет фона',
viewedBy: 'Просмотрели',
storyDetails: 'Информация',
viewCountSuffix: 'посмотрели эту историю',
storyPrivacyNote: 'Истории зашифрованы и исчезнут через 24 часа. Сообщения о прочтении видны только автору.',
storiesEditor: 'Редактор историй',
tools: 'Инструменты',
privacyNote: 'Выберите, кто может просматривать вашу новую историю.',
allUsers: 'Все пользователи',
onlyContacts: 'Только контакты',
selectedContacts: 'Выбранные контакты',
yourStory: 'Ваша история',
stickers: 'Стикеры',
brush: 'Кисть',
filters: 'Фильтры',
report: 'Пожаловаться',
groupSettings: 'Настройки группы',
editGroupName: 'Изменить название',
addMember: 'Добавить участника',
@@ -567,6 +585,24 @@ const translations = {
loadChatsError: 'Error loading chats',
loadMessagesError: 'Error loading messages',
unreadMessages: 'Unread messages',
typeYourStoryPlaceholder: 'Write your story...',
uploadMedia: 'Upload media',
chooseBackground: 'Background',
viewedBy: 'Viewed by',
storyDetails: 'Details',
viewCountSuffix: 'people viewed this story',
storyPrivacyNote: 'Stories are encrypted and disappear in 24 hours. View receipts are only visible to you.',
storiesEditor: 'Stories Editor',
tools: 'Tools',
privacyNote: 'Choose who can view your new story.',
allUsers: 'All users',
onlyContacts: 'Only contacts',
selectedContacts: 'Selected contacts',
yourStory: 'Your story',
stickers: 'Stickers',
brush: 'Brush',
filters: 'Filters',
report: 'Report',
attachmentDiscardConfirm: 'You have attached files. If you switch to another chat, they will be lost. Continue?',
},
} as const;

View File

@@ -21,7 +21,8 @@ import ChatListItem from '../../../modules/chats/presentation/components/ChatLis
import NewChatModal from '../../../modules/chats/presentation/components/NewChatModal';
import UserProfile from '../../../modules/users/presentation/components/UserProfile';
import SideMenu from './SideMenu';
import StoryViewer, { CreateStoryModal } from '../../../modules/stories/presentation/components/StoryViewer';
import StoryViewer from '../../../modules/stories/presentation/components/StoryViewer';
import { CreateStoryModal } from '../../../modules/stories/presentation/components/CreateStoryModal';
import { useStoryStore } from '../../../modules/stories/application/storyStore';
const API_URL = import.meta.env.VITE_API_URL || '';

View File

@@ -10,7 +10,7 @@ export class StoryApi {
return httpClient.request<StoryGroup>(`/stories/user/${userId}`);
}
static async createStory(data: { type: string; mediaUrl?: string; content?: string; bgColor?: string }) {
static async createStory(data: { type: string; mediaUrl?: string; content?: string; bgColor?: string; privacy?: string; filter?: string }) {
return httpClient.request<{ id: string }>('/stories', {
method: 'POST',
body: JSON.stringify(data),

View File

@@ -0,0 +1,419 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Camera, Type, Check, Palette, Smile, Brush, Filter, RotateCcw, Eye, MoreVertical, Globe, Users, Lock, ChevronLeft, Trash2, Scissors, Music, Eraser, Minus, Plus, Maximize, Trash, SlidersHorizontal, Sun, Contrast, Droplets, Wind, Pipette, Type as FontIcon, AlignCenter } from 'lucide-react';
import { useLang } from '../../../../core/infrastructure/i18n';
import { ChatApi } from '../../../chats/infrastructure/chatApi';
import { StoryApi } from '../../infrastructure/storyApi';
import Cropper from 'react-easy-crop';
import { getMediaUrl } from '../../../../core/utils/utils';
import { useAuthStore } from '../../../auth/application/authStore';
import Avatar from '../../../../core/presentation/components/ui/Avatar';
import Picker from '@emoji-mart/react';
import data from '@emoji-mart/data';
const FONTS = [
{ id: 'inter', name: 'Standard', family: 'Inter, sans-serif' },
{ id: 'serif', name: 'Playfair', family: '"Playfair Display", serif' },
{ id: 'mono', name: 'Retro', family: '"Fira Code", monospace' },
{ id: 'script', name: 'Elegant', family: '"Dancing Script", cursive' },
{ id: 'black', name: 'Impact', family: '"Archivo Black", sans-serif' },
];
interface TextObject {
id: number;
text: string;
x: number;
y: number;
color: string;
font: string;
size: number;
scale: number;
}
interface CreateStoryModalProps {
onClose: () => void;
onCreated: () => void;
}
export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps) {
const { t } = useLang();
const { user } = useAuthStore();
const [mediaFile, setMediaFile] = useState<File | null>(null);
const [mediaPreview, setMediaPreview] = useState<string | null>(null);
const [croppedPreview, setCroppedPreview] = useState<string | null>(null);
const [bgColor, setBgColor] = useState('#1e1e2e');
const [isUploading, setIsUploading] = useState(false);
const [tool, setTool] = useState<'none' | 'crop' | 'brush' | 'stickers' | 'filters' | 'privacy' | 'text'>('none');
const [privacy, setPrivacy] = useState<'all' | 'contacts' | 'selected'>('all');
const [textObjects, setTextObjects] = useState<TextObject[]>([]);
const [selectedTextId, setSelectedTextId] = useState<number | null>(null);
const [stickers, setStickers] = useState<Array<{ id: number, emoji: string, x: number, y: number, scale: number }>>([]);
const [selectedStickerId, setSelectedStickerId] = useState<number | null>(null);
const [brushColor, setBrushColor] = useState('#ffffff');
const [brushSize, setBrushSize] = useState(8);
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [rotation, setRotation] = useState(0);
const [croppedAreaPixels, setCroppedAreaPixels] = useState<any>(null);
const [adjustments, setAdjustments] = useState({ brightness: 100, contrast: 100, saturate: 100, sepia: 0, grayscale: 0 });
const fileInputRef = useRef<HTMLInputElement>(null);
const stageRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const isDrawingRef = useRef(false);
// Scroll Lock implementation
useEffect(() => {
const originalStyle = window.getComputedStyle(document.body).overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = originalStyle;
};
}, []);
const TARGET_W = 1080;
const TARGET_H = 1920;
const SCALE_FACTOR = TARGET_W / 400;
const getFilterCss = () => `brightness(${adjustments.brightness}%) contrast(${adjustments.contrast}%) saturate(${adjustments.saturate}%) sepia(${adjustments.sepia}%) grayscale(${adjustments.grayscale}%)`;
const handleMediaSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setMediaFile(file);
const url = URL.createObjectURL(file);
setMediaPreview(url);
setCroppedPreview(null);
setTool('crop');
};
const removeMedia = () => {
setMediaFile(null);
setMediaPreview(null);
setCroppedPreview(null);
setCroppedAreaPixels(null);
setTool('none');
};
const addText = () => {
const id = Date.now();
setTextObjects([...textObjects, { id, text: 'Текст', x: 20, y: 40, color: '#ffffff', font: FONTS[0].family, size: 42, scale: 1 }]);
setSelectedTextId(id);
setTool('none');
setTimeout(() => setTool('text'), 50);
};
const addSticker = (emoji: any) => {
const id = Date.now();
setStickers([...stickers, { id, emoji: emoji.native, x: 30, y: 50, scale: 1.5 }]);
setSelectedStickerId(id);
};
const clearCanvas = () => {
const canvas = canvasRef.current;
if (canvas) {
const ctx = canvas.getContext('2d');
if (ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
}
};
const loadImage = (src: string): Promise<HTMLImageElement> => {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = async () => {
try {
if ('decode' in img) await img.decode();
resolve(img);
} catch(e) {
resolve(img); // Fallback
}
};
img.onerror = (e) => reject(e);
img.src = src;
});
};
const handleCropDone = async () => {
if (croppedAreaPixels && mediaPreview) {
try {
const image = await loadImage(mediaPreview);
const canvas = document.createElement('canvas');
canvas.width = croppedAreaPixels.width;
canvas.height = croppedAreaPixels.height;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.drawImage(image, croppedAreaPixels.x, croppedAreaPixels.y, croppedAreaPixels.width, croppedAreaPixels.height, 0, 0, croppedAreaPixels.width, croppedAreaPixels.height);
canvas.toBlob(blob => {
if (blob) {
const url = URL.createObjectURL(blob);
setCroppedPreview(url);
}
}, 'image/jpeg', 0.95);
} catch (e) {
console.error('Crop bake error', e);
}
}
setTool('none');
};
const handlePublish = async () => {
setIsUploading(true);
try {
const canvas = document.createElement('canvas');
canvas.width = TARGET_W;
canvas.height = TARGET_H;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas init failed');
// 1. BG Color
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, TARGET_W, TARGET_H);
// 2. Base Image Layer
const baseSrc = croppedPreview || mediaPreview;
if (baseSrc && (mediaFile?.type.startsWith('image/') || !mediaFile)) {
const img = await loadImage(baseSrc);
ctx.save();
ctx.filter = getFilterCss();
if (!croppedPreview && croppedAreaPixels) {
ctx.drawImage(img, croppedAreaPixels.x, croppedAreaPixels.y, croppedAreaPixels.width, croppedAreaPixels.height, 0, 0, TARGET_W, TARGET_H);
} else {
ctx.drawImage(img, 0, 0, TARGET_W, TARGET_H);
}
ctx.restore();
}
// 3. Brush Layer
if (canvasRef.current) {
ctx.drawImage(canvasRef.current, 0, 0, TARGET_W, TARGET_H);
}
// 4. Overlays
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
stickers.forEach(s => {
ctx.font = `${s.scale * 100}px "Apple Color Emoji", "Segoe UI Emoji", serif`;
ctx.fillText(s.emoji, (s.x / 100) * TARGET_W, (s.y / 100) * TARGET_H);
});
textObjects.forEach(t => {
const fontSize = t.size * SCALE_FACTOR * t.scale;
ctx.font = `bold ${fontSize}px ${t.font}`;
ctx.fillStyle = t.color;
ctx.fillText(t.text, (t.x / 100) * TARGET_W, (t.y / 100) * TARGET_H);
});
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(b => b ? resolve(b) : reject('Blob error'), 'image/jpeg', 0.95);
});
const fileToUpload = new File([blob], `story_${Date.now()}.jpg`, { type: 'image/jpeg' });
const { url } = await ChatApi.uploadFile(fileToUpload);
if (!url) throw new Error('Upload failed');
// Using PascalCase matching the DTO just in case, and including original content
await StoryApi.createStory({
type: 'image',
mediaUrl: url,
bgColor: bgColor,
privacy: privacy, // Note: might not be in backend but safe to send
} as any);
onCreated();
onClose();
} catch (e) {
console.error('Publishing error:', e);
} finally {
setIsUploading(false);
}
};
return (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 z-[200] bg-[#000] flex flex-col font-body text-white overflow-hidden selection:bg-primary/20">
<style>{`
@import url('https://fonts.googleapis.com/css2?family=Dancing+Script:wght@700&family=Playfair+Display:ital,wght@1,900&family=Fira+Code:wght@700&family=Archivo+Black&display=swap');
`}</style>
<header className="h-20 px-8 flex items-center justify-between border-b border-white/5 bg-[#0a0a0a] z-50 shadow-2xl">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary/20 flex items-center justify-center text-primary border border-primary/20 italic font-black shadow-[0_0_20px_rgba(var(--primary-rgb),0.3)]">S</div>
<h1 className="text-sm font-black uppercase tracking-widest italic text-zinc-400">Stories Editor</h1>
</div>
<div className="flex items-center gap-4">
<button onClick={() => { setStickers([]); setTextObjects([]); setMediaPreview(null); setMediaFile(null); setCroppedPreview(null); clearCanvas(); setBgColor('#1e1e2e'); }} className="px-6 py-3 text-[10px] font-black uppercase tracking-widest text-zinc-500 hover:text-white border border-white/5 rounded-xl hover:bg-white/5 transition-all">Сброс</button>
<button onClick={handlePublish} disabled={isUploading} className="px-8 py-3 bg-primary text-on-primary rounded-xl font-black text-xs uppercase tracking-widest hover:scale-105 active:scale-95 disabled:opacity-50 transition-all shadow-xl shadow-primary/20">
{isUploading ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : 'Опубликовать'}
</button>
</div>
</header>
<main className="flex-1 flex overflow-hidden">
<section className="flex-1 flex items-center justify-center p-12 relative bg-[#070707]">
<div ref={stageRef} className="relative w-full max-w-[400px] aspect-[9/16] bg-black rounded-[3.5rem] border-[12px] border-[#161616] overflow-hidden shadow-[0_50px_100px_-30px_rgba(0,0,0,0.9)] ring-1 ring-white/5">
<div className="absolute inset-0 transition-colors duration-700" style={{ backgroundColor: bgColor }}>
{(croppedPreview || mediaPreview) && (
<div className="absolute inset-0" style={{ filter: getFilterCss() }}>
{mediaFile?.type.startsWith('video/') ? (
<video src={mediaPreview!} className="w-full h-full object-cover" autoPlay muted loop />
) : (
<img src={croppedPreview || mediaPreview!} className="w-full h-full object-cover" alt="base" />
)}
<button onClick={e => { e.stopPropagation(); removeMedia(); }} className="absolute top-8 right-8 w-12 h-12 bg-black/60 text-white rounded-2xl hover:bg-red-500 transition-all z-40 backdrop-blur-md flex items-center justify-center border border-white/10 group"><Trash2 size={20} className="group-hover:scale-110 transition-transform" /></button>
</div>
)}
<canvas ref={canvasRef} width={TARGET_W} height={TARGET_H} className={`absolute inset-0 z-10 w-full h-full ${tool === 'brush' ? 'cursor-crosshair' : 'pointer-events-none'}`} onMouseDown={e => { isDrawingRef.current = true; const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; const rect = canvas.getBoundingClientRect(); const x = (e.clientX - rect.left) * (canvas.width / rect.width); const y = (e.clientY - rect.top) * (canvas.height / rect.height); ctx.beginPath(); ctx.moveTo(x, y); ctx.strokeStyle = brushColor; ctx.lineWidth = brushSize * SCALE_FACTOR; ctx.lineCap = 'round'; }} onMouseMove={e => { if (!isDrawingRef.current) return; const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; const rect = canvas.getBoundingClientRect(); const x = (e.clientX - rect.left) * (canvas.width / rect.width); const y = (e.clientY - rect.top) * (canvas.height / rect.height); ctx.lineTo(x, y); ctx.stroke(); }} onMouseUp={() => isDrawingRef.current = false} />
<AnimatePresence>
{stickers.map(s => (
<motion.div key={s.id} drag dragConstraints={stageRef} dragMomentum={false} initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }} className={`absolute z-20 cursor-grab active:cursor-grabbing p-4 ${selectedStickerId === s.id ? 'ring-2 ring-primary bg-white/5 rounded-[2rem] backdrop-blur-sm' : ''}`} style={{ left: `${s.x}%`, top: `${s.y}%`, fontSize: `${s.scale * 40}px` }} onClick={() => {setSelectedStickerId(s.id); setSelectedTextId(null); setTool('stickers');}}>
{s.emoji}
{selectedStickerId === s.id && <button onClick={(e) => { e.stopPropagation(); setStickers(prev => prev.filter(st => st.id !== s.id)); setSelectedStickerId(null); }} className="absolute -top-3 -right-3 w-8 h-8 bg-red-500 rounded-full flex items-center justify-center text-white shadow-xl border-2 border-[#161616]"><X size={16}/></button>}
</motion.div>
))}
{textObjects.map(t => (
<motion.div key={t.id} drag dragConstraints={stageRef} dragMomentum={false} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className={`absolute z-30 cursor-grab active:cursor-grabbing p-4 group ${selectedTextId === t.id ? 'ring-2 ring-primary bg-white/5 rounded-[2rem] backdrop-blur-md' : ''}`} style={{ left: `${t.x}%`, top: `${t.y}%`, color: t.color, fontFamily: t.font, fontSize: `${t.size}px`, transform: `scale(${t.scale})` }} onClick={() => {setSelectedTextId(t.id); setSelectedStickerId(null); setTool('text');}}>
{t.text}
{selectedTextId === t.id && <button onClick={(e) => { e.stopPropagation(); setTextObjects(prev => prev.filter(to => to.id !== t.id)); setSelectedTextId(null); }} className="absolute -top-3 -right-3 w-8 h-8 bg-red-500 rounded-full flex items-center justify-center text-white shadow-xl border-2 border-[#161616]"><X size={16}/></button>}
</motion.div>
))}
</AnimatePresence>
{!mediaPreview && textObjects.length === 0 && stickers.length === 0 && (
<button onClick={() => fileInputRef.current?.click()} className="absolute inset-0 flex flex-col items-center justify-center gap-6 text-zinc-800 hover:text-white transition-all group">
<div className="w-20 h-20 rounded-full border-2 border-dashed border-zinc-800 flex items-center justify-center group-hover:border-primary group-hover:bg-primary/5 group-hover:scale-110 transition-all duration-500">
<Camera size={40} className="group-hover:rotate-12 transition-transform" />
</div>
<span className="text-[9px] font-black uppercase tracking-[0.4em]">Начать создание</span>
</button>
)}
</div>
{tool === 'crop' && mediaPreview && (
<div className="absolute inset-0 z-[100] bg-black">
<Cropper image={mediaPreview} crop={crop} zoom={zoom} rotation={rotation} aspect={9/16} onCropChange={setCrop} onZoomChange={setZoom} onCropComplete={(_, pixels) => setCroppedAreaPixels(pixels)} />
<button onClick={handleCropDone} className="absolute bottom-10 left-1/2 -translate-x-1/2 px-10 py-4 bg-primary text-white rounded-full font-black text-xs uppercase z-[110] shadow-2xl hover:scale-105 active:scale-95 transition-all">Готово</button>
</div>
)}
</div>
<input ref={fileInputRef} type="file" accept="image/*,video/*" className="hidden" onChange={handleMediaSelect} />
</section>
<aside className="w-[440px] bg-[#0a0a0a] border-l border-white/5 flex flex-col overflow-y-auto">
<div className="p-10 space-y-12 pb-24">
<div className="grid grid-cols-2 gap-4">
{[
{ id: 'media', icon: <Camera />, label: 'Медиа', action: () => fileInputRef.current?.click() },
{ id: 'text', icon: <Type />, label: 'Текст', action: addText },
{ id: 'stickers', icon: <Smile />, label: 'Стикеры', active: tool === 'stickers' },
{ id: 'brush', icon: <Brush />, label: 'Кисти', active: tool === 'brush' },
{ id: 'filters', icon: <SlidersHorizontal />, label: 'Коррекция', active: tool === 'filters' },
].map(item => (
<button key={item.id} onClick={() => { if(item.action) item.action(); setTool(item.id as any); }} className={`flex flex-col items-start justify-between h-32 p-7 rounded-[2.5rem] border transition-all duration-500 ${tool === item.id ? 'bg-primary/10 border-primary/40 text-white shadow-[0_15px_40px_-10px_rgba(var(--primary-rgb),0.3)]' : 'bg-[#121212] border-white/5 text-zinc-500 hover:text-white hover:border-white/10'}`}>
<div className={tool === item.id ? 'text-primary' : 'text-zinc-700'}>{item.icon}</div>
<span className="text-[10px] font-black uppercase tracking-widest">{item.label}</span>
</button>
))}
</div>
<AnimatePresence mode="wait">
{tool === 'text' && selectedTextId && (
<motion.div key="text-tool" initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} className="space-y-8 bg-[#121212] p-8 rounded-[2.5rem] border border-white/5 shadow-2xl">
<div className="flex flex-col gap-4">
<span className="text-[10px] font-black uppercase text-primary tracking-widest">Контент</span>
<input value={textObjects.find(t => t.id === selectedTextId)?.text} onChange={e => setTextObjects(prev => prev.map(t => t.id === selectedTextId ? { ...t, text: e.target.value } : t))} className="bg-transparent border-b border-white/10 w-full py-2 text-2xl font-black outline-none italic" autoFocus />
</div>
<div className="space-y-4">
<span className="text-[10px] font-black uppercase text-zinc-600 tracking-widest">Типографика</span>
<div className="flex flex-wrap gap-2">
{FONTS.map(f => (
<button key={f.id} onClick={() => setTextObjects(prev => prev.map(t => t.id === selectedTextId ? { ...t, font: f.family } : t))} className={`px-5 py-2 rounded-2xl text-[9px] font-black uppercase transition-all ${textObjects.find(t => t.id === selectedTextId)?.font === f.family ? 'bg-primary text-white shadow-lg' : 'bg-white/5 text-zinc-500 hover:text-white'}`} style={{ fontFamily: f.family }}>{f.name}</button>
))}
</div>
</div>
<div className="space-y-4">
<div className="flex justify-between text-[10px] font-black uppercase text-zinc-600"><span>Размер</span><span className="text-white">{textObjects.find(t => t.id === selectedTextId)?.size}px</span></div>
<input type="range" min="12" max="150" value={textObjects.find(t => t.id === selectedTextId)?.size} onChange={e => setTextObjects(prev => prev.map(t => t.id === selectedTextId ? { ...t, size: parseInt(e.target.value) } : t))} className="w-full accent-primary h-1 bg-white/5 rounded-full" />
</div>
<div className="flex items-center justify-between pt-6 border-t border-white/5">
<span className="text-[10px] font-black uppercase text-zinc-600">Цвет текста</span>
<div className="w-12 h-12 rounded-full border-2 border-white/10 relative shadow-inner" style={{ backgroundColor: textObjects.find(t => t.id === selectedTextId)?.color }}>
<input type="color" value={textObjects.find(t => t.id === selectedTextId)?.color} onChange={e => setTextObjects(prev => prev.map(t => t.id === selectedTextId ? { ...t, color: e.target.value } : t))} className="absolute inset-0 opacity-0 w-full h-full cursor-pointer" />
<Pipette size={18} className="absolute inset-0 m-auto mix-blend-difference" />
</div>
</div>
</motion.div>
)}
{tool === 'brush' && (
<motion.div key="brush-tool" initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} className="p-8 rounded-[2.5rem] bg-[#121212] border border-white/5 space-y-10 text-center shadow-2xl">
<div className="w-24 h-24 rounded-full mx-auto border-4 border-white/10 relative shadow-inner cursor-pointer group" style={{ backgroundColor: brushColor }}>
<input type="color" value={brushColor} onChange={e => setBrushColor(e.target.value)} className="absolute inset-0 opacity-0 w-full h-full cursor-pointer" />
<Pipette size={32} className="absolute inset-0 m-auto mix-blend-difference group-hover:scale-125 transition-transform" />
</div>
<div className="space-y-4">
<div className="flex justify-between text-[10px] font-bold text-zinc-600 uppercase tracking-widest"><span>Толщина</span><span className="text-white">{brushSize}px</span></div>
<input type="range" min="1" max="100" value={brushSize} onChange={e => setBrushSize(parseInt(e.target.value))} className="w-full accent-primary h-1 bg-white/5 rounded-full" />
</div>
<button onClick={clearCanvas} className="w-full py-5 text-[10px] font-black uppercase tracking-[0.2em] text-red-500 bg-red-500/10 rounded-3xl hover:bg-red-500 hover:text-white transition-all border border-red-500/20">Очистить холст</button>
</motion.div>
)}
{tool === 'stickers' && (
<motion.div key="sticker-tool" initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} className="bg-[#121212] rounded-[2.5rem] border border-white/5 overflow-hidden shadow-2xl">
<Picker data={data} onEmojiSelect={addSticker} theme="dark" set="native" locale="ru" previewPosition="none" skinTonePosition="none" />
</motion.div>
)}
{tool === 'filters' && (
<motion.div key="filters-tool" initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} className="p-8 rounded-[2.5rem] bg-[#121212] border border-white/5 space-y-8 shadow-2xl">
<h4 className="text-[10px] font-black uppercase tracking-widest text-primary">Мастер коррекции</h4>
<div className="space-y-6">
{[
{ id: 'brightness', icon: <Sun size={14}/>, label: 'Яркость', min: 40, max: 160 },
{ id: 'contrast', icon: <Contrast size={14}/>, label: 'Контраст', min: 40, max: 160 },
{ id: 'saturate', icon: <Droplets size={14}/>, label: 'Насыщенность', min: 0, max: 250 },
{ id: 'sepia', icon: <Palette size={14}/>, label: 'Сепия', min: 0, max: 100 },
].map(adj => (
<div key={adj.id} className="space-y-3">
<div className="flex items-center justify-between text-[10px] font-bold text-zinc-700 uppercase">
<div className="flex items-center gap-2">{adj.icon} {adj.label}</div>
<span className="text-white">{adjustments[adj.id as keyof typeof adjustments]}%</span>
</div>
<input type="range" min={adj.min} max={adj.max} value={adjustments[adj.id as keyof typeof adjustments]} onChange={e => setAdjustments(prev => ({ ...prev, [adj.id]: parseInt(e.target.value) }))} className="w-full accent-primary h-1 bg-white/5 rounded-full" />
</div>
))}
</div>
</motion.div>
)}
{!mediaPreview && tool === 'none' && (
<motion.div key="bg-tool" initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="p-10 rounded-[2.5rem] bg-[#121212] flex flex-col items-center gap-8 border border-white/5 shadow-2xl">
<div className="flex flex-col items-center gap-2">
<Palette size={24} className="text-primary" />
<span className="text-[10px] font-black uppercase text-zinc-500 tracking-widest">Цвет фона</span>
</div>
<div className="w-28 h-28 rounded-full border-[6px] border-white/5 relative shadow-inner transition-transform hover:scale-105 active:scale-95 cursor-pointer" style={{ backgroundColor: bgColor }}>
<input type="color" value={bgColor} onChange={e => setBgColor(e.target.value)} className="absolute inset-0 opacity-0 w-full h-full cursor-pointer" />
<Pipette size={36} className="absolute inset-0 m-auto mix-blend-difference" />
</div>
<span className="text-[9px] font-bold text-zinc-700 uppercase">Нажмите для выбора цвета</span>
</motion.div>
)}
</AnimatePresence>
<div className="pt-10 border-t border-white/5" />
</div>
</aside>
</main>
</motion.div>
);
}

View File

@@ -0,0 +1,78 @@
import { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import Picker from '@emoji-mart/react';
import data from '@emoji-mart/data';
import { useLang } from '../../../../core/infrastructure/i18n';
interface EmojiOnlyPickerProps {
onSelect: (emoji: string) => void;
onClose: () => void;
}
export default function EmojiOnlyPicker({ onSelect, onClose }: EmojiOnlyPickerProps) {
const { lang } = useLang();
const anchorRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
useEffect(() => {
const update = () => {
const el = anchorRef.current?.parentElement;
if (!el) return;
const rect = el.getBoundingClientRect();
const w = 350; // Slightly narrower for stories
let left = rect.right - w;
if (left < 8) left = 8;
setPos({ top: rect.top - 8, left });
};
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, []);
return (
<>
<div ref={anchorRef} className="hidden" />
{createPortal(
<>
<div className="fixed inset-0 z-[9990]" onClick={onClose} />
<div
className="fixed z-[9991] rounded-[2rem] shadow-[0_32px_64px_-16px_rgba(0,0,0,0.6)] border border-white/10 overflow-hidden flex flex-col backdrop-blur-3xl"
onClick={(e) => e.stopPropagation()}
style={{
width: 350,
height: 400,
bottom: pos ? `${window.innerHeight - pos.top}px` : undefined,
left: pos ? pos.left : undefined,
background: 'rgba(19, 19, 19, 0.85)',
visibility: pos ? 'visible' : 'hidden',
}}
>
<div className="px-5 py-3 border-b border-white/5 bg-black/20 flex-shrink-0">
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-primary">{lang === 'ru' ? 'Выбор эмодзи' : 'Choose Emoji'}</span>
</div>
<div className="flex-1 overflow-hidden">
<Picker
data={data}
onEmojiSelect={(e: { native: string }) => onSelect(e.native)}
theme="dark"
locale={lang === 'ru' ? 'ru' : 'en'}
set="native"
previewPosition="none"
skinTonePosition="search"
perLine={8}
emojiSize={24}
emojiButtonSize={32}
maxFrequentRows={1}
navPosition="top"
dynamicWidth={false}
background="transparent"
/>
</div>
</div>
</>,
document.body
)}
</>
);
}

205
his/knot_1/code.html Normal file
View File

@@ -0,0 +1,205 @@
<!DOCTYPE html>
<html class="dark" lang="ru"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
"surface-bright": "#3a3939",
"secondary-fixed-dim": "#adc6ff",
"on-surface-variant": "#c1c6d7",
"on-surface": "#e5e2e1",
"tertiary": "#ffb595",
"surface-container": "#201f1f",
"tertiary-fixed-dim": "#ffb595",
"surface-dim": "#131313",
"on-primary-fixed-variant": "#004493",
"on-background": "#e5e2e1",
"primary-container": "#4b8eff",
"surface": "#131313",
"on-tertiary-fixed-variant": "#7c2e00",
"on-primary-container": "#00285c",
"surface-tint": "#adc6ff",
"secondary": "#adc6ff",
"on-secondary-fixed": "#001a41",
"inverse-on-surface": "#313030",
"primary-fixed": "#d8e2ff",
"on-tertiary": "#571e00",
"error-container": "#93000a",
"primary-fixed-dim": "#adc6ff",
"surface-variant": "#353534",
"tertiary-container": "#ef6719",
"tertiary-fixed": "#ffdbcc",
"on-secondary-fixed-variant": "#26467d",
"error": "#ffb4ab",
"surface-container-lowest": "#0e0e0e",
"surface-container-low": "#1c1b1b",
"on-tertiary-container": "#4c1a00",
"surface-container-high": "#2a2a2a",
"on-error": "#690005",
"on-primary-fixed": "#001a41",
"background": "#131313",
"primary": "#adc6ff",
"on-error-container": "#ffdad6",
"inverse-surface": "#e5e2e1",
"inverse-primary": "#005bc1",
"on-primary": "#002e69",
"on-tertiary-fixed": "#351000",
"outline-variant": "#414755",
"on-secondary-container": "#98b5f3",
"secondary-container": "#26467d",
"on-secondary": "#082f65",
"secondary-fixed": "#d8e2ff",
"outline": "#8b90a0",
"surface-container-highest": "#353534"
},
fontFamily: {
"headline": ["Inter"],
"body": ["Inter"],
"label": ["Inter"]
},
borderRadius: {"DEFAULT": "0.25rem", "lg": "0.5rem", "xl": "0.75rem", "full": "9999px"},
},
},
}
</script>
<style>
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
.perspective-1000 { perspective: 1000px; }
.glass-panel { background: rgba(14, 14, 14, 0.85); backdrop-filter: blur(24px); }
</style>
</head>
<body class="bg-surface-container-lowest text-on-surface font-body selection:bg-primary/30">
<!-- Main App Background Shell (SideNavBar included as layout anchor) -->
<div class="flex min-h-screen">
<!-- Side Navigation (Shared Component Integration) -->
<aside class="h-screen w-72 flex flex-col fixed left-0 top-0 bg-[#1C1B1B] border-r border-[#C1C6D7]/15 py-8 px-4 z-0 opacity-40 blur-sm pointer-events-none">
<div class="mb-12">
<h1 class="text-2xl font-black text-[#ADC6FF] tracking-tighter">Knot</h1>
<p class="text-[0.7rem] text-[#C1C6D7] opacity-60">Digital Sovereignty</p>
</div>
<nav class="flex-1 space-y-2">
<div class="flex items-center gap-4 px-4 py-3 rounded-xl text-[#C1C6D7] opacity-70">
<span class="material-symbols-outlined" data-icon="chat">chat</span>
<span class="font-medium">Messages</span>
</div>
<div class="flex items-center gap-4 px-4 py-3 rounded-xl text-[#ADC6FF] font-bold border-r-4 border-[#ADC6FF] bg-[#2A2A2A]">
<span class="material-symbols-outlined" data-icon="motion_photos_on">motion_photos_on</span>
<span class="font-medium">Stories</span>
</div>
<div class="flex items-center gap-4 px-4 py-3 rounded-xl text-[#C1C6D7] opacity-70">
<span class="material-symbols-outlined" data-icon="group">group</span>
<span class="font-medium">Contacts</span>
</div>
</nav>
</aside>
<!-- Fullscreen Story Player Overlay -->
<main class="fixed inset-0 z-50 flex items-center justify-center glass-panel">
<!-- Close Button (Top Right) -->
<button class="absolute top-8 right-12 p-3 rounded-full bg-surface-container-high text-on-surface hover:bg-surface-variant transition-all active:scale-95">
<span class="material-symbols-outlined text-3xl" data-icon="close">close</span>
</button>
<!-- Navigation Controls (Desktop Sides) -->
<div class="absolute inset-y-0 left-0 w-1/4 flex items-center justify-start pl-12 group cursor-pointer">
<button class="p-5 rounded-2xl bg-surface-container-high/40 text-on-surface opacity-0 group-hover:opacity-100 transition-all hover:bg-surface-container-high active:scale-90">
<span class="material-symbols-outlined text-4xl" data-icon="arrow_back_ios">arrow_back_ios</span>
</button>
</div>
<div class="absolute inset-y-0 right-0 w-1/4 flex items-center justify-end pr-12 group cursor-pointer">
<button class="p-5 rounded-2xl bg-surface-container-high/40 text-on-surface opacity-0 group-hover:opacity-100 transition-all hover:bg-surface-container-high active:scale-90">
<span class="material-symbols-outlined text-4xl" data-icon="arrow_forward_ios">arrow_forward_ios</span>
</button>
</div>
<!-- Content Area: The Knot Square Player -->
<div class="relative w-[500px] h-[500px] md:w-[600px] md:h-[600px] bg-surface-container shadow-[0px_40px_80px_rgba(0,0,0,0.6)] overflow-hidden rounded-[20px] ring-1 ring-white/10 flex flex-col group">
<!-- Progress Bars (Top Layer) -->
<div class="absolute top-4 inset-x-4 flex gap-1.5 z-20">
<div class="h-1 flex-1 bg-white/20 rounded-full overflow-hidden">
<div class="h-full bg-white w-full rounded-full"></div>
</div>
<div class="h-1 flex-1 bg-white/20 rounded-full overflow-hidden">
<div class="h-full bg-white w-3/4 rounded-full"></div>
</div>
<div class="h-1 flex-1 bg-white/10 rounded-full overflow-hidden"></div>
<div class="h-1 flex-1 bg-white/10 rounded-full overflow-hidden"></div>
</div>
<!-- Story Header -->
<div class="absolute top-8 inset-x-4 flex items-center justify-between z-20">
<div class="flex items-center gap-3">
<img alt="Profile avatar" class="w-10 h-10 rounded-[20px] object-cover ring-2 ring-primary/30" data-alt="Close-up portrait of a man with short dark hair and a slight smile against a blurred dark studio background" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBkanT9_jEOf4CZE2X2_eEqC_GCp6fxbUz1F9qIZ6ieQzdWNBeSj3XCj22GVDUWbv48Ld2e8JVrnaBW4Yz4aV_46fgnC-UEGl9L3f9g46k-TnfY54bmw1BcM1PXXbykyWJiT-I-pScJrA7zIravdSzlI23Sq3DjFoPIHV5DsdNyhoFqfdrAtdlXzHoYYLwihGxW4f1MsXsH94QZbvrUlNaEJoFhJ5nat50tL-6SQXtfsqyRTrlToNi69MoHlfFQ0B2tBe9tKDd79mg"/>
<div>
<p class="font-bold text-on-surface text-sm tracking-tight">Александр Волков</p>
<p class="text-[0.7rem] text-on-surface/60 font-medium uppercase tracking-wider">3 часа назад</p>
</div>
</div>
<button class="p-2 text-white/80 hover:text-white">
<span class="material-symbols-outlined" data-icon="more_horiz">more_horiz</span>
</button>
</div>
<!-- Main Story Media (The Visual) -->
<div class="relative flex-1 bg-surface-container-low overflow-hidden">
<img alt="Story content" class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-105" data-alt="Modern minimalist interior with a futuristic holographic display showing neon blue nodes and connections in a dark room" src="https://lh3.googleusercontent.com/aida-public/AB6AXuDGBGK-rh8MQ9BriB35jlOb5GFNScnDFvB_0urQZVOblRrbvIC0ZKakNtHM37Mj6MNFej-P8fAhVBvh1lz6dAYHRjhAIyZzy2GTQbVAqR9US98IxnBkKfwYFquHmbZdHlb_LzWSjiNTYIzs6KPy7cax60U58D5DBXx1QcSYLBgSM0ecfwUv5Afo-73hoKHdRHUCmWUsHa_zlsq9FHoYGPgPgLF4wq4cTksT3a6NH-cWkMWHcqGb27vW-qK7GGETowz7HfsTePDzPpQ"/>
<!-- Decorative Radial Gradient for Legibility -->
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-black/60 pointer-events-none"></div>
</div>
<!-- Interaction Layer (Bottom) -->
<div class="absolute bottom-0 inset-x-0 p-6 space-y-6 z-20 translate-y-4 group-hover:translate-y-0 transition-transform duration-300">
<!-- Reactions -->
<div class="flex items-center justify-center gap-4">
<button class="w-10 h-10 flex items-center justify-center bg-surface-container-high/60 backdrop-blur-md rounded-xl hover:scale-125 transition-transform">🔥</button>
<button class="w-10 h-10 flex items-center justify-center bg-surface-container-high/60 backdrop-blur-md rounded-xl hover:scale-125 transition-transform">❤️</button>
<button class="w-10 h-10 flex items-center justify-center bg-surface-container-high/60 backdrop-blur-md rounded-xl hover:scale-125 transition-transform">😂</button>
<button class="w-10 h-10 flex items-center justify-center bg-surface-container-high/60 backdrop-blur-md rounded-xl hover:scale-125 transition-transform">😮</button>
<button class="w-10 h-10 flex items-center justify-center bg-surface-container-high/60 backdrop-blur-md rounded-xl hover:scale-125 transition-transform">🙌</button>
</div>
<!-- Quick Reply Input -->
<div class="flex items-center gap-3">
<div class="relative flex-1">
<input class="w-full bg-white/10 border-none rounded-xl px-4 py-3.5 text-sm text-white placeholder:text-white/40 focus:ring-1 focus:ring-primary/50 focus:bg-white/15 transition-all" placeholder="Ответить на историю..." type="text"/>
</div>
<button class="w-12 h-12 flex items-center justify-center bg-primary text-on-primary rounded-xl hover:opacity-90 active:scale-95 transition-all shadow-lg shadow-primary/20">
<span class="material-symbols-outlined" data-icon="send">send</span>
</button>
</div>
</div>
</div>
<!-- Content Context (Right Side Panel - Web View Only) -->
<div class="hidden xl:flex flex-col ml-8 w-80 space-y-4">
<div class="p-6 rounded-[20px] bg-surface-container-low border border-white/5 space-y-4">
<h3 class="text-sm font-bold text-on-surface uppercase tracking-widest opacity-40">Сейчас смотрят</h3>
<div class="flex -space-x-2">
<img alt="Viewer" class="w-8 h-8 rounded-[12px] ring-2 ring-surface-container-low object-cover" data-alt="Close-up portrait of a person with a leather jacket in an urban setting" src="https://lh3.googleusercontent.com/aida-public/AB6AXuDTekzRVh2HmWzQkGUZFqD7B1bZbRQi1lFexN5HzQGfMBYtAS1ABwcx0kcMAOS3XPQbs3z4zyqvgf_HsNQQTgBKVfqQpQEVBje7nDG0x-2ivLz8C70JH2RRccBkRx7lNocyYifmcX_kqKl9ZCC4xzG8UB5kRMQYMSGrQ_dqCd95scLE0Q2fJUH5o45YgNqtDPGr0ZP_8OKJYVCneimZ6N7XdGskRgzRGGt53Io367reKHBX6VmBXTNzrNdDcg89sxSu9qJJTY4E8Rs"/>
<img alt="Viewer" class="w-8 h-8 rounded-[12px] ring-2 ring-surface-container-low object-cover" data-alt="Smiling woman with dark hair in a bright studio lighting" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBQlRr2eU6Xnu1SUNz3TJxqXRHegiZAFT28ihtFKAz_JHcfGEuyz7IcTIHZrre_O-mwcNWF-tuQ8BGSkj5xVE81ZJRCMnh_cvf-EELAT7tNgyOnStLIYvPmsTP6WftQiOVCxlhRcbTmC9H5TvTYJU2EgIOvyQ8gff8CUM9QlwW7moMoDzvxbB-qX4kz7bHuV5BAstO2IENT9CGTGpzaZnovFYdee7BjwmtRkUwFTZCMQg4ppsw7IDccN2F05DEKNqL0StFIxeBsbhQ"/>
<img alt="Viewer" class="w-8 h-8 rounded-[12px] ring-2 ring-surface-container-low object-cover" data-alt="Portrait of a young man with a serious expression in natural light" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBRumq5TYF2uSUg0ZiqTdpMZAu9pw9buEyoGaXnYAUYxsMydBeqVFjnap6WO7SSIFH0_q4tCBjLRUphikyiAKQ24Wu1tnT087QUeFw0KAdE9xYN4DvzcnPgOMEBguyPh8HEUhdwHO2J3exIMmwMquTnfEftqfP0_192XSDbPU70UH7uiriTrKvkRdNV9qKRT8wEqDXBLxaHXQANq56L8-z55qaFtcyAMKieYZxOQQfeNZCjd9JcmBVyhgxyOl_MBpak16-Paoi2F6o"/>
<div class="w-8 h-8 rounded-[12px] ring-2 ring-surface-container-low bg-surface-container-high flex items-center justify-center text-[10px] font-bold text-primary">+12</div>
</div>
<div class="pt-4 border-t border-white/5">
<p class="text-xs text-on-surface-variant leading-relaxed">В этой истории Александр делится концептами нового децентрализованного интерфейса для Knot.</p>
</div>
</div>
<div class="p-4 rounded-[20px] bg-surface-container-low border border-white/5 flex items-center justify-between group cursor-pointer hover:bg-surface-container transition-colors">
<div class="flex items-center gap-3">
<span class="material-symbols-outlined text-primary" data-icon="share">share</span>
<span class="text-sm font-medium">Поделиться</span>
</div>
<span class="material-symbols-outlined opacity-0 group-hover:opacity-100 transition-opacity" data-icon="chevron_right">chevron_right</span>
</div>
</div>
</main>
</div>
<!-- Background Decoration Nodes (The Knot Identity) -->
<div class="fixed inset-0 pointer-events-none overflow-hidden -z-10">
<div class="absolute top-1/4 left-1/4 w-96 h-96 bg-primary/10 rounded-full blur-[120px]"></div>
<div class="absolute bottom-1/4 right-1/4 w-[500px] h-[500px] bg-primary-container/10 rounded-full blur-[160px]"></div>
</div>
</body></html>

BIN
his/knot_1/screen.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

267
his/knot_2/code.html Normal file
View File

@@ -0,0 +1,267 @@
<!DOCTYPE html><html class="dark" lang="ru"><head>
<meta charset="utf-8">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<title>Knot - Создание историй</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&amp;display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet">
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
"surface-bright": "#3a3939",
"secondary-fixed-dim": "#adc6ff",
"on-surface-variant": "#c1c6d7",
"on-surface": "#e5e2e1",
"tertiary": "#ffb595",
"surface-container": "#201f1f",
"tertiary-fixed-dim": "#ffb595",
"surface-dim": "#131313",
"on-primary-fixed-variant": "#004493",
"on-background": "#e5e2e1",
"primary-container": "#4b8eff",
"surface": "#131313",
"on-tertiary-fixed-variant": "#7c2e00",
"on-primary-container": "#00285c",
"surface-tint": "#adc6ff",
"secondary": "#adc6ff",
"on-secondary-fixed": "#001a41",
"inverse-on-surface": "#313030",
"primary-fixed": "#d8e2ff",
"on-tertiary": "#571e00",
"error-container": "#93000a",
"primary-fixed-dim": "#adc6ff",
"surface-variant": "#353534",
"tertiary-container": "#ef6719",
"tertiary-fixed": "#ffdbcc",
"on-secondary-fixed-variant": "#26467d",
"error": "#ffb4ab",
"surface-container-lowest": "#0e0e0e",
"surface-container-low": "#1c1b1b",
"on-tertiary-container": "#4c1a00",
"surface-container-high": "#2a2a2a",
"on-error": "#690005",
"on-primary-fixed": "#001a41",
"background": "#131313",
"primary": "#adc6ff",
"on-error-container": "#ffdad6",
"inverse-surface": "#e5e2e1",
"inverse-primary": "#005bc1",
"on-primary": "#002e69",
"on-tertiary-fixed": "#351000",
"outline-variant": "#414755",
"on-secondary-container": "#98b5f3",
"secondary-container": "#26467d",
"on-secondary": "#082f65",
"secondary-fixed": "#d8e2ff",
"outline": "#8b90a0",
"surface-container-highest": "#353534"
},
fontFamily: {
"headline": ["Inter"],
"body": ["Inter"],
"label": ["Inter"]
},
borderRadius: { "DEFAULT": "0.25rem", "lg": "0.5rem", "xl": "0.75rem", "2xl": "1.25rem", "full": "9999px" },
},
},
}
</script>
<style>
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
.kinetic-gradient {
background: linear-gradient(135deg, #ADC6FF 0%, #4B8EFF 100%);
}
.glass-panel {
background: rgba(53, 53, 52, 0.6);
backdrop-filter: blur(20px);
}
body {
background-color: #131313;
color: #E5E2E1;
font-family: 'Inter', sans-serif;
}
</style>
</head>
<body class="overflow-hidden">
<!-- SideNavBar Component -->
<aside class="h-screen w-72 flex flex-col fixed left-0 top-0 bg-[#1C1B1B] border-r border-[#C1C6D7]/15 py-8 px-4 z-50">
<div class="mb-10 px-4">
<div class="flex items-center gap-3">
<span class="text-2xl font-black text-[#ADC6FF] tracking-tighter" style="">Knot</span>
</div>
<p class="text-xs text-[#C1C6D7] opacity-60 mt-1 uppercase tracking-widest font-bold" style="">Digital Sovereignty</p>
</div>
<nav class="flex-1 space-y-1">
<a class="flex items-center gap-4 py-3 px-4 rounded-xl text-[#C1C6D7] opacity-70 hover:bg-[#2A2A2A] hover:text-[#E5E2E1] transition-all duration-300" href="#" style="">
<span class="material-symbols-outlined" style="">chat</span>
<span class="font-medium" style="">Messages</span>
</a>
<a class="flex items-center gap-4 py-3 px-4 rounded-xl text-[#ADC6FF] font-bold border-r-4 border-[#ADC6FF] bg-[#2A2A2A] transition-all duration-300" href="#" style="">
<span class="material-symbols-outlined" style="font-variation-settings: &quot;FILL&quot; 1;">motion_photos_on</span>
<span class="font-medium" style="">Stories</span>
</a>
<a class="flex items-center gap-4 py-3 px-4 rounded-xl text-[#C1C6D7] opacity-70 hover:bg-[#2A2A2A] hover:text-[#E5E2E1] transition-all duration-300" href="#" style="">
<span class="material-symbols-outlined" style="">group</span>
<span class="font-medium" style="">Contacts</span>
</a>
<a class="flex items-center gap-4 py-3 px-4 rounded-xl text-[#C1C6D7] opacity-70 hover:bg-[#2A2A2A] hover:text-[#E5E2E1] transition-all duration-300" href="#" style="">
<span class="material-symbols-outlined" style="">settings</span>
<span class="font-medium" style="">Settings</span>
</a>
<a class="flex items-center gap-4 py-3 px-4 rounded-xl text-[#C1C6D7] opacity-70 hover:bg-[#2A2A2A] hover:text-[#E5E2E1] transition-all duration-300" href="#" style="">
<span class="material-symbols-outlined" style="">inventory_2</span>
<span class="font-medium" style="">Archive</span>
</a>
</nav>
<div class="mt-auto pt-6 border-t border-[#C1C6D7]/10 space-y-1">
<a class="flex items-center gap-4 py-3 px-4 rounded-xl text-[#C1C6D7] opacity-70 hover:bg-[#2A2A2A] transition-all duration-300" href="#" style="">
<span class="material-symbols-outlined" style="">help_outline</span>
<span class="font-medium" style="">Help</span>
</a>
<a class="flex items-center gap-4 py-3 px-4 rounded-xl text-[#C1C6D7] opacity-70 hover:bg-[#2A2A2A] transition-all duration-300" href="#" style="">
<span class="material-symbols-outlined" style="">logout</span>
<span class="font-medium" style="">Logout</span>
</a>
<div class="mt-6 flex items-center gap-3 px-4 py-2">
<img alt="User profile avatar" class="w-10 h-10 rounded-2xl object-cover" data-alt="Close-up portrait of a stylish man with a modern haircut in cinematic lighting with deep shadows" src="https://lh3.googleusercontent.com/aida-public/AB6AXuDjMvOAp5dF8TT2a_n3fKgdUqNB2_6aujDigL5K245BxxJenNvlVwpbRe-kef4RtiIaAr0GtNSpOW3VoriWbG7mPbCFjVH9wG18_CGGavXXYPTbSngv9mPNFoJQiqozL12jQEqlpnMYrYnU6bHBiIuC9yAx0j_TuAUvOjjZPuaEG4Q8c6lZGx_NQderFrGnqp7-Hz2-bKe60AV6a947pFP53nKiOpbXUNphj6l6lmiG68Lv8_U1-ImIi68h5o79t6xldiDr-nq_5bw" style="">
<div class="overflow-hidden">
<p class="text-sm font-bold text-[#E5E2E1] truncate" style="">Alex Kovalev</p>
<p class="text-[10px] text-[#C1C6D7] opacity-50 uppercase tracking-tighter" style="">Pro Account</p>
</div>
</div>
</div>
</aside>
<!-- Main Workspace -->
<main class="ml-72 flex flex-col h-screen bg-[#0E0E0E]">
<!-- TopNavBar Component -->
<header class="flex justify-between items-center w-full px-12 h-20 bg-[#0E0E0E]/80 backdrop-blur-xl sticky top-0 z-40 shadow-[0px_20px_40px_rgba(0,0,0,0.4)]">
<div class="flex items-center gap-4">
<span class="material-symbols-outlined text-[#ADC6FF]" style="">auto_fix_high</span>
<h1 class="text-xl font-bold text-[#E5E2E1]" style="">Stories Editor</h1>
</div>
<div class="flex items-center gap-6">
<div class="flex items-center gap-4 mr-4">
<button class="p-2 text-[#C1C6D7] hover:opacity-80 transition-opacity active:scale-95" style="">
<span class="material-symbols-outlined" style="">history</span>
</button>
<button class="p-2 text-[#C1C6D7] hover:opacity-80 transition-opacity active:scale-95" style="">
<span class="material-symbols-outlined" style="">visibility</span>
</button>
<button class="p-2 text-[#C1C6D7] hover:opacity-80 transition-opacity active:scale-95" style="">
<span class="material-symbols-outlined" style="">more_vert</span>
</button>
</div>
<button class="kinetic-gradient text-[#00285c] px-8 py-2.5 rounded-2xl font-bold text-sm transition-all duration-300 hover:brightness-110 active:scale-95" style="">
Опубликовать
</button>
</div>
</header>
<!-- Canvas Area -->
<section class="flex flex-1 overflow-hidden">
<!-- Left: Preview Area -->
<div class="flex-1 flex flex-col items-center justify-center p-8 relative">
<!-- Background Decoration -->
<div class="absolute inset-0 overflow-hidden opacity-10 pointer-events-none">
<div class="absolute -top-20 -left-20 w-96 h-96 bg-primary-container blur-[120px] rounded-full"></div>
<div class="absolute -bottom-20 -right-20 w-96 h-96 bg-tertiary blur-[120px] rounded-full"></div>
</div>
<!-- Story Preview Card -->
<div class="relative w-[380px] h-[680px] bg-[#1C1B1B] rounded-[2.5rem] shadow-2xl p-4 flex flex-col overflow-hidden border border-[#C1C6D7]/5">
<div class="relative flex-1 rounded-[2rem] overflow-hidden group">
<img alt="Story media preview" class="w-full h-full object-cover" data-alt="Abstract vibrant purple and blue neon city lights at night with beautiful bokeh and cinematic atmosphere" src="https://lh3.googleusercontent.com/aida-public/AB6AXuDqwMSRrc0EWBto1LhYYkXkBPP1oSAfy_DHqpLV0YLSRLQby86fuP3UrkhV1fmHMBFRTJfmk9can64Kd9LD4cMbJsGUzon9eGqN60frIGtVfGOx7mAQvlQVAUHl58fDH_sHwCvA1sp0GmlWYni6ZjCHpOAmoKBgXBEaAaacxW4X7ZlbLpjOGlCEJ3TZXSekBuQocQNZkZX0TlCDceRDOJj5zfrCodDhH8b-KrFOVLUHyb9cCgoFedaVeKsQEIK5q-vf2tQE68HlgYQ" style="">
<!-- Overlay UI -->
<div class="absolute top-6 left-6 flex items-center gap-3">
<img alt="User avatar" class="w-8 h-8 rounded-xl border border-white/20" src="https://lh3.googleusercontent.com/aida-public/AB6AXuAv-A-5Fk8LMDuoJ9jszmLoIUX1JjYYqFUzHyI8O_7N_3wCIPOqbbG9MsHQyzclDdEKOyt7DowtlRQSwPDleUYp504iToHOUNBsDNimYpkygzpc0gMprJWpbatQp24aE5J6Nyeegn9k2RPizb4BfnNmsLhofcRymN0H4gtbm8ttItLm_Xo0vIwENLZshlXkH5RkvEcFYH95a92-BKDqcHFWT_xPNxsUuxSwXyGfUkZIrm0QOZ9XoPxCmaJNc-tR_sDGDhzxt5sEKW0" style="">
<span class="text-white text-xs font-bold drop-shadow-md" style="">Ваша история</span>
</div>
<div class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent pointer-events-none"></div>
<!-- Sample Text Element -->
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-center pointer-events-none">
<h2 class="text-4xl font-black text-white italic tracking-tighter drop-shadow-2xl" style="">VIBES</h2>
</div>
</div>
<!-- Bottom Controls in Card -->
<div class="mt-4 flex justify-center pb-2">
<div class="h-1 w-12 bg-[#E5E2E1]/20 rounded-full"></div>
</div>
</div>
<!-- Upload Trigger Overlay -->
<div class="mt-8 flex gap-4">
<button class="flex items-center gap-2 bg-surface-container-high text-on-surface px-6 py-3 rounded-2xl font-medium hover:bg-surface-variant transition-colors border border-outline-variant/10" style="">
<span class="material-symbols-outlined" style="">add_a_photo</span>
Загрузить медиа
</button>
</div>
</div>
<!-- Right: Toolbar & Panel -->
<aside class="w-96 bg-[#1C1B1B] border-l border-[#C1C6D7]/15 flex flex-col p-8 gap-8 overflow-y-auto">
<div>
<h3 class="text-sm font-black uppercase tracking-widest text-[#ADC6FF] mb-6" style="">Инструменты</h3>
<div class="grid grid-cols-2 gap-4">
<button class="flex flex-col items-center justify-center p-4 rounded-2xl bg-[#2A2A2A] hover:bg-[#353534] transition-all group" style="">
<span class="material-symbols-outlined text-2xl mb-2 text-[#C1C6D7] group-hover:text-[#ADC6FF]" style="">title</span>
<span class="text-xs font-medium" style="">Текст</span>
</button>
<button class="flex flex-col items-center justify-center p-4 rounded-2xl bg-[#2A2A2A] hover:bg-[#353534] transition-all group" style="">
<span class="material-symbols-outlined text-2xl mb-2 text-[#C1C6D7] group-hover:text-[#ADC6FF]" style="">mood</span>
<span class="text-xs font-medium" style="">Стикеры</span>
</button>
<button class="flex flex-col items-center justify-center p-4 rounded-2xl bg-[#2A2A2A] hover:bg-[#353534] transition-all group" style="">
<span class="material-symbols-outlined text-2xl mb-2 text-[#C1C6D7] group-hover:text-[#ADC6FF]" style="">brush</span>
<span class="text-xs font-medium" style="">Кисти</span>
</button>
<button class="flex flex-col items-center justify-center p-4 rounded-2xl bg-[#2A2A2A] hover:bg-[#353534] transition-all group" style="">
<span class="material-symbols-outlined text-2xl mb-2 text-[#C1C6D7] group-hover:text-[#ADC6FF]" style="">filter_vintage</span>
<span class="text-xs font-medium" style="">Фильтры</span>
</button>
</div>
</div>
<div class="space-y-4">
<h3 class="text-sm font-black uppercase tracking-widest text-[#ADC6FF]" style="">Приватность</h3>
<p class="text-xs text-[#C1C6D7] opacity-60" style="">Выберите, кто может просматривать вашу новую историю.</p>
<div class="space-y-2">
<label class="flex items-center justify-between p-4 rounded-2xl bg-[#2A2A2A] border border-transparent hover:border-[#ADC6FF]/20 cursor-pointer transition-all" style="">
<div class="flex items-center gap-3">
<span class="material-symbols-outlined text-[#ADC6FF]" style="">public</span>
<span class="text-sm font-bold" style="">Все пользователи</span>
</div>
<input checked="" class="w-5 h-5 border-2 border-outline rounded-full text-[#ADC6FF] focus:ring-offset-0 focus:ring-[#ADC6FF] bg-transparent" name="privacy" type="radio">
</label>
<label class="flex items-center justify-between p-4 rounded-2xl bg-[#1C1B1B] border border-outline-variant/30 hover:border-[#ADC6FF]/20 cursor-pointer transition-all" style="">
<div class="flex items-center gap-3">
<span class="material-symbols-outlined text-on-surface-variant" style="">group</span>
<span class="text-sm font-bold" style="">Только контакты</span>
</div>
<input class="w-5 h-5 border-2 border-outline rounded-full text-[#ADC6FF] focus:ring-offset-0 focus:ring-[#ADC6FF] bg-transparent" name="privacy" type="radio">
</label>
<label class="flex items-center justify-between p-4 rounded-2xl bg-[#1C1B1B] border border-outline-variant/30 hover:border-[#ADC6FF]/20 cursor-pointer transition-all" style="">
<div class="flex items-center gap-3">
<span class="material-symbols-outlined text-on-surface-variant" style="">lock</span>
<span class="text-sm font-bold" style="">Выбранные контакты</span>
</div>
<input class="w-5 h-5 border-2 border-outline rounded-full text-[#ADC6FF] focus:ring-offset-0 focus:ring-[#ADC6FF] bg-transparent" name="privacy" type="radio">
</label>
</div>
</div>
<div class="mt-auto space-y-4">
<div class="p-4 rounded-2xl bg-[#ADC6FF]/5 border border-[#ADC6FF]/10">
<div class="flex items-start gap-3">
<span class="material-symbols-outlined text-[#ADC6FF] text-sm" style="">info</span>
<p class="text-[11px] text-[#C1C6D7] leading-relaxed" style="">
Истории Knot зашифрованы и автоматически удаляются через 24 часа.&nbsp;</p>
</div>
</div>
<button class="w-full bg-surface-container-highest text-on-surface py-4 rounded-2xl font-bold text-sm transition-all hover:bg-surface-variant active:scale-98" style="">
Отменить
</button>
</div>
</aside>
</section>
</main>
</body></html>

BIN
his/knot_2/screen.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 525 KiB

View File

@@ -0,0 +1,99 @@
# Design System Specification: The Kinetic Nexus
## 1. Overview & Creative North Star
This design system is built for an era of decentralized, secure communication. It moves away from the "standard app" aesthetic toward a philosophy we call **"The Digital Curator."**
The Creative North Star focuses on **Kinetic Sophistication**. We reject the static, boxy nature of traditional messaging apps in favor of an environment that feels alive and interconnected. By leveraging the concept of "Knots"—points where data, security, and human connection meet—we use intentional asymmetry, overlapping layers, and high-contrast editorial typography to create a high-end, bespoke experience. The interface should feel like a premium tool for modern digital sovereignty: fast, lightweight, and mathematically precise.
---
## 2. Colors & Surface Philosophy
### Color Tokens
Our palette is rooted in absolute depth and electric precision.
* **Dark Mode (Primary):** `surface-container-lowest` (#0E0E0E) as the foundation.
* **Light Mode:** `surface-bright` (#FFFFFF) and `surface-container` (#F5F5F7).
* **Accent:** `primary` (#ADC6FF) and `primary-container` (#4B8EFF).
* **Text:** High contrast `on-surface` (#E5E2E1) for readability, with `on-surface-variant` (#C1C6D7) for secondary metadata.
### The "No-Line" Rule
To achieve a premium editorial feel, **1px solid borders are strictly prohibited** for sectioning or containment. Boundaries must be defined through:
1. **Background Shifts:** Placing a `surface-container-low` panel atop a `surface` background.
2. **Tonal Transitions:** Using depth to separate the message list from the conversation view.
### Surface Hierarchy & Nesting
Treat the UI as a series of nested physical layers. Use the `surface-container` tiers to indicate importance:
* **Level 0 (Base):** `surface` (#131313) - The "desk" everything sits on.
* **Level 1 (Sections):** `surface-container-low` (#1C1B1B) - Large sidebars or navigation areas.
* **Level 2 (Active Elements):** `surface-container-high` (#2A2A2A) - Active chat bubbles or focused cards.
### Signature Textures
Avoid flat blocks of color. For Primary CTAs and Hero moments, use a **Linear Gradient** transition from `primary` (#ADC6FF) to `primary-container` (#4B8EFF) at a 135-degree angle. This provides "visual soul" and a sense of movement.
---
## 3. Typography
We utilize **Inter** for its technical precision and flawless Cyrillic support.
* **Display (lg/md/sm):** Used for onboarding and empty states. Tighten letter-spacing to `-0.02em` for an authoritative, editorial look.
* **Headline (lg/md/sm):** Used for top-level navigation headers. These should feel intentional and spacious.
* **Title (lg/md):** Used for contact names and group titles. Bold weight.
* **Body (lg/md/sm):** The workhorse for messages. `body-lg` (1rem) is the default for chat bubbles to ensure premium legibility.
* **Label (md/sm):** Reserved for timestamps and micro-copy. Use `label-md` with `on-surface-variant`.
**Hierarchy Note:** To move beyond generic UI, contrast the font weights aggressively. Pair a `display-sm` bold header with a `body-sm` light subhead to create a sophisticated "magazine" layout within the app settings.
---
## 4. Elevation & Depth
### The Layering Principle
Depth is achieved through **Tonal Stacking**. For example, a chat bubble (`surface-container-highest`) sitting on a message thread (`surface-container-low`) creates a natural, soft lift.
### Ambient Shadows
Shadows are only permitted for floating elements (e.g., Modals, Context Menus).
* **Spec:** `0px 20px 40px rgba(0, 0, 0, 0.4)`
* Shadows must be extra-diffused. The shadow color should be a tinted version of `surface-container-lowest` to ensure it feels like a natural lighting occlusion rather than a "drop shadow."
### Glassmorphism & Ghost Borders
For floating UI (like a bottom-pinned navigation bar), use **Backdrop Blur (20px)** combined with a 60% opacity `surface-variant`.
* **Ghost Border Fallback:** If a container requires definition against a similar tone, use the `outline-variant` token at **15% opacity**. Never 100%.
---
## 5. Components
### Avatars & Media Containers
Reflecting the 'Knot' name, we move away from circles.
* **Spec:** Square with a **20px border radius** (`xl` / 1.5rem). This creates a distinct, modern silhouette that aligns with the square-ish nature of modern technical interfaces.
### Buttons
* **Primary:** Gradient fill (Primary to Primary-Container), white text, `xl` rounding.
* **Secondary:** `surface-container-highest` fill, `primary` text. No border.
* **States:** On hover, increase the brightness of the gradient by 10%. On press, scale the component down to `0.98`.
### Input Fields
* **Visual Style:** Ghost-style inputs. No background fill, only a bottom `outline-variant` (20% opacity). Upon focus, the bottom border transitions to a `primary` glow with a subtle backdrop shift to `surface-container-low`.
### Chat Bubbles & Lists
* **NO DIVIDERS.** Use the Spacing Scale (`6` / 1.5rem) to separate message groups.
* **Asymmetry:** User messages should have a slightly different corner radius on the "anchor" side to feel like they are "plugged into" the side of the screen.
### Interconnected Line Motifs
Subtle 2px lines (using `outline-variant`) can be used to visually connect threads or "knots" of data in the background, reinforcing the brand identity of a federated network.
---
## 6. Do's and Don'ts
### Do
* **DO** use whitespace as a functional element. Give text room to breathe (Spacing `8` or `10`).
* **DO** use "Kinetic" transitions. Elements should slide and fade into place using a `cubic-bezier(0.16, 1, 0.3, 1)` easing profile.
* **DO** prioritize the Cyrillic rendering of Inter, ensuring line-heights are generous enough to avoid "cramping" tall characters.
### Don't
* **DON'T** use pure #000000 for backgrounds; it kills the perception of depth. Stick to the `surface-container-lowest` (#0E0E0E).
* **DON'T** use 1px solid dividers to separate list items. Use tonal shifts or vertical space.
* **DON'T** use round avatars. The 20px radius square is our signature signature element.
* **DON'T** default to high-opacity borders. They create "visual noise" and make the system feel like a generic template.