Перейти к содержимому
FastOffice

Двое в одном документе

Две рамки рядом: двигаете выделение в одной — во второй виден чужой курсор с именем; и прямо на экране написано, что видны только окна этого браузера.

две рамки рядом: двигаете выделение в одной — во второй виден чужой курсор с именем; и прямо на экране написано, что видны только окна этого браузера
открыть в новой вкладке ↗Работающий пример. Ниже — его код целиком, файл за файлом.

В рамке выше работает тот самый проект, чей код лежит ниже, — собранный, но не переписанный: те же файлы, только пропущенные через сборку. Считает в нём не ядро, а дублёр транспорта, поэтому числа в ячейках ничего не значат: смотреть нужно на код.

Страница, которую открывает браузер.

index.html
<!doctype html>
<!--
Двое в одном документе — НАСТОЯЩИЙ МАЛЕНЬКИЙ ПРОЕКТ.
Не «страница витрины», а тот проект, который встраивающий копирует к себе
целиком. Галерея примеров ЗАПУСКАЕТ эти файлы и ПОКАЗЫВАЕТ их же.
-->
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Двое в одном документе — @f1</title>
</head>
<body>
<div data-example="presence" class="flex h-screen w-screen flex-col bg-base-100 text-base-content">
<header class="border-b border-base-300 px-4 py-2">
<div class="text-sm font-semibold">Двое в одном документе</div>
<div class="text-xs opacity-70">
Две рамки — два настоящих сеанса: свой транспорт контракта, своя личность, свой конец
канала. Подвиньте выделение слева — справа появится курсор Анны с её именем и цветом.
Видят ли рамки правки друг друга — решает не канал, а КНИГА: без
<code>?core</code> она у каждой своя, и чужая правка сюда не попадёт (полоса
присутствия говорит об этом словами); с общим хозяином ядра книга одна на всех, и
правка соседа появляется здесь потому, что это ТА ЖЕ правка.
</div>
<!--
КАНАЛ ВЫБИРАЕТСЯ НАСТРОЙКОЙ, А НЕ ПЕРЕПИСЫВАНИЕМ КОДА: порт один,
реализаций три, и продукт ни одной из них не знает.
-->
<div class="mt-1 text-xs opacity-60">
Канал: <code>?link=memory</code> — обе рамки в одной вкладке (умолчание);
<code>?link=broadcast</code> — окна одного браузера, откройте страницу дважды;
<code>?link=socket&amp;as=anna</code> — через наш ретранслятор
(<code>npm run presence:host</code>), тогда во второй вкладке
<code>?link=socket&amp;as=boris</code> — и курсоры видны между вкладками.
</div>
<!--
КНИГА — ОТДЕЛЬНАЯ НАСТРОЙКА, И ЭТО НЕ ТО ЖЕ, ЧТО КАНАЛ.
Канал возит присутствие; книга приезжает от транспорта контракта.
-->
<div class="mt-1 text-xs opacity-60">
Книга: без <code>?core</code> — своя у каждой рамки (умолчание);
<code>?core=http://127.0.0.1:5507</code> — одна на всех через хозяина ядра
(<code>npm run core:host</code>, а без собранного ядра
<code>npm run core:host -- --stand-in</code>).
</div>
</header>
<div class="flex min-h-0 flex-1 gap-2 p-2">
<section
data-presence-frame="anna"
class="flex min-h-0 min-w-0 flex-1 flex-col rounded-card border border-base-300"
>
<div class="border-b border-base-300 px-2 py-1 text-xs font-medium">Окно Анны</div>
<div class="seat flex min-h-0 flex-1 flex-col"></div>
</section>
<section
data-presence-frame="boris"
class="flex min-h-0 min-w-0 flex-1 flex-col rounded-card border border-base-300"
>
<div class="border-b border-base-300 px-2 py-1 text-xs font-medium">Окно Бориса</div>
<div class="seat flex min-h-0 flex-1 flex-col"></div>
</section>
</div>
</div>
<script type="module" src="./src/main.ts"></script>
</body>
</html>

Точка входа: здесь лист создаётся и сюда же приходят события.

src/main.ts
import { createSheetsCore, type SheetsCore } from "@f1/preset-sheets-core";
import { broadcastLink, memoryLinks, socketLink, type Peer, type PresenceLink } from "@f1/presence";
import { book } from "./book.js";
import { hostAsks } from "./host.js";
import "./styles.css";
/**
* ДВОЕ В ОДНОМ ДОКУМЕНТЕ — И НИ ОДНОГО ВЫДУМАННОГО УЧАСТНИКА.
*
* Две рамки рядом — это ДВА НАСТОЯЩИХ СЕАНСА: у каждой своя транспорт контракта,
* своя сборка листа, своя личность и свой конец канала. Они не делят ничего,
* кроме канала присутствия, — ровно как два окна браузера. Подвиньте выделение
* в левой рамке, и в правой появится курсор Анны с её именем и цветом.
*
* КАНАЛ ВЫБИРАЕТСЯ НАСТРОЙКОЙ, И ЭТО ГЛАВНОЕ, ЧТО ПОКАЗЫВАЕТ ПРИМЕР. Порт
* (`PresenceLink`) один, реализаций три, и продукт ни одной из них не знает:
*
* · `?link=memory` (умолчание) — обе рамки в одной вкладке. `BroadcastChannel`
* не возвращает весть отправителю (и правильно делает: иначе человек видел бы
* рядом собственный курсор), а обе рамки здесь на одном его конце — то есть
* через него они друг друга не услышали бы вовсе. Витрине канал в памяти
* нужен затем, чтобы показать разговор без просьбы «откройте второе окно».
* · `?link=broadcast` — окна ОДНОГО браузера. Откройте страницу дважды.
* · `?link=socket&relay=ws://127.0.0.1:5508` — СОКЕТ: разные вкладки, разные
* браузеры, разные машины. Ретранслятор поднимается `npm run presence:host`.
* Вместе с сокетом полезен `?as=anna` — рамка ОДНА, и вкладка становится
* одним человеком, а не двумя сразу.
*
* ЛИЧНОСТЬ ЗДЕСЬ — ОТ «ХОЗЯИНА СТРАНИЦЫ», как и в жизни. Она не выдумана
* оболочкой и не хранится ею: два объекта ниже — это то, что в продукте придёт
* от учётной записи. Выдуманный в TypeScript пользователь закрыл бы дверь в
* совместность навсегда (`docs/READINESS_COLLAB.md`).
*
* ОДНА КНИГА НА ВСЕХ — ОТДЕЛЬНАЯ НАСТРОЙКА, И ЭТО НЕ ТО ЖЕ САМОЕ, ЧТО КАНАЛ
* (расширили столбец — сосед этого не видит). Канал возит ПРИСУТСТВИЕ; книга
* приезжает от ТРАНСПОРТА:
*
* · без `?core` (умолчание) у каждой рамки СВОЯ книга-заглушка. Сосед виден,
* курсор ездит, а его правки и ширины столбцов сюда не попадают — потому что
* это другая книга. Полоса присутствия говорит это словами, а не молчит;
* · `?core=http://127.0.0.1:5507` — ОДИН ХОЗЯИН ЯДРА на всех (`npm run
* core:host`, а без собранного ядра — `npm run core:host -- --stand-in`).
* Книга буквально одна: правка соседа появляется здесь потому, что это ТА ЖЕ
* правка. Сливать нечего — нечему расходиться.
*
* МЕДЛЕННАЯ ДОРОГА — ТРЕТЬЯ НАСТРОЙКА (`?pace=400`). Она не
* про присутствие и не про книгу, а про ЦЕНУ: продукт меряет, во что обходится
* перечитывание окна после чужой правки, и выше бюджета отклика произносит на
* полосе, что книга работает в одиночку. На заглушке эта цена — доли
* миллисекунды, и порога не видно; на живом ядре с книгой владельца то же
* чтение стоит 5 953 мс. Задержку объявляет ХОЗЯИН СТРАНИЦЫ, продукт её
* ИЗМЕРЯЕТ, и ни одно число на экране не выдумано.
* СТРОГИЙ РЕЖИМ — ТРЕТЬЯ НАСТРОЙКА, И ОНА НЕ ЗАВИСИТ ОТ ПЕРВЫХ ДВУХ.
* `?mode=strict` — место, где сосед ОТКРЫЛ правку, занято: его ячейка помечена
* его цветом пунктиром со словом «правит», а правка туда не начинается. Без
* признака (умолчание) замков нет вовсе — как в Excel. Замок объявляется тем же
* присутствием, что и курсор, и отпускается сам: правка кончилась, участник
* ушёл, связь пропала.
*
* ПРАВКИ ПО КАНАЛУ НЕ ЕЗДЯТ НИ В ОДНОМ ИЗ ДВУХ СЛУЧАЕВ. По нему едет ИЗВЕСТИЕ
* («книга изменилась, перечитай окно»), а содержимое сосед спрашивает у ядра
* тем же запросом окна, что и при прокрутке. Согласование расходящихся копий
* этим не решается и решаться не должно (`docs/RECON_MERGE.md`).
*/
const { locale, link: kind, relay, only, core, pace, mode } = hostAsks();
const anna: Peer = { id: "anna", name: "Анна Петрова" };
const boris: Peer = { id: "boris", name: "Борис Иванов" };
/**
* Ключ общего пространства — В ЖИЗНИ ЭТО ДОКУМЕНТ.
*
* Здесь он один на весь пример и назван явно: подставить его умолчанием внутри
* пакета значило бы свести два РАЗНЫХ документа в один разговор.
*/
const space = "витрина-присутствия";
/* ОДИН РАЗГОВОР НА ДВОИХ, если канал в памяти: обе рамки берут его концы. */
const room = memoryLinks("this-browser");
const linkFor = (): PresenceLink => {
if (kind === "socket") return socketLink({ space, url: relay });
if (kind === "broadcast") return broadcastLink({ space });
return room();
};
const seats: SheetsCore[] = [anna, boris]
.filter((self) => only === null || self.id === only)
.map((self) =>
createSheetsCore({
mount: document.querySelector<HTMLElement>(`[data-presence-frame="${self.id}"] .seat`)!,
/* СВОЯ ТРАНСПОРТ У КАЖДОГО — два сеанса, а не два вида одной книги. А вот
КНИГА за ними одна или две, решает хозяин страницы адресом `?core=…`:
без него у каждой рамки своя книга-заглушка (и полоса присутствия
говорит об этом словами), с ним — один хозяин ядра на всех, и правки
соседа видны здесь потому, что это ТЕ ЖЕ правки. */
transport: book(core, pace),
locale,
/**
* КНИГА ВЫШЕ ОКНА ЛИСТА — ИНАЧЕ СТРАНИЦА НЕ МОЖЕТ ПОКАЗАТЬ ТО, ЧТО ОБЕЩАЕТ.
*
* Здесь стояло 200 строк, и с ними настройка `?pace=` (порог совместности)
* была недостижима НИКАКИМ жестом посетителя. Причина измерена
* зондом в самом транспорте витрины: первое чтение окна уходит
* прямоугольником 517×143, следующие — 987×33, то есть окно ШИРЕ всей
* книги. Прокрутка из него не выходит, второго чтения не бывает, а цена
* дороги считается медианой минимум ДВУХ проб (`PACE_MIN_SAMPLES`,
* @f1/contract) — значит `pace()` честно молчит, и четвёртой честной
* строки на полосе нет ни при какой задержке. Страница при этом прямо
* зовёт посетителя попробовать `?pace=400`.
*
* Три тысячи — не «побольше на всякий случай», а втрое над измеренным
* окном в 987 строк. Сузится окно (полоса `fix/viewport-overask`) — станет
* только вернее; вырастет вдвое — витрина всё ещё работает.
*/
rows: 3000,
cols: 26,
/* СТРОГИЙ РЕЖИМ — НАСТРОЙКА ХОЗЯИНА СТРАНИЦЫ (`?mode=strict`), а
не устройство листа: по умолчанию его нет, как и в Excel. Со строгим
режимом ячейка, где сосед открыл правку, помечена его цветом и именем,
и правка туда не начинается — а полоса присутствия говорит это словами.
Без него всё как раньше: пишут все, последняя правка побеждает, и
проигравшего называют. */
presence: { link: linkFor(), self, mode },
}),
);
/* Рамка без сеанса на странице не остаётся: пустая рамка с чужим именем
выглядит как «человек здесь, но ничего не делает» — то есть врёт. */
if (only !== null) {
for (const peer of [anna, boris]) {
if (peer.id === only) continue;
document.querySelector(`[data-presence-frame="${peer.id}"]`)?.remove();
}
}
import.meta.hot?.dispose(() => seats.forEach((seat) => seat.destroy()));

Книга примера: данные, с которыми лист открывается.

src/book.ts
import { createMockTransport, createServiceHostTransport, type ContractTransport } from "@f1/contract";
/**
* КНИГА-ЗАГЛУШКА — ВМЕСТО НАСТОЯЩЕГО ЯДРА.
*
* Транспорт контракта — единственное место, где лист разговаривает с ядром
* (инвариант №1). В продукте здесь стоит `wasm` или мост хоста.
*
* КАЖДАЯ РАМКА ЗОВЁТ ЭТУ ФУНКЦИЮ СВОИМ ВЫЗОВОМ, и это часть честности примера:
* два сеанса не делят ни транспорт, ни книгу, ни состояние — они делят только
* КАНАЛ ПРИСУТСТВИЯ. Один транспорт на двоих незаметно превратил бы пример в
* «одна книга, два вида», а это совсем другое утверждение.
*
* ПРАВКИ ДРУГ ДРУГА ОНИ ПОЭТОМУ НЕ ВИДЯТ — и не должны: пример показывает
* ПРИСУТСТВИЕ, и ровно это на нём и написано. Это и есть тот случай, который
* со стороны выглядит ошибкой: сосед виден, а расширенный им столбец — нет.
*
* А ВОТ КАК ЭТО ЧИНИТСЯ — И ЧИНИТСЯ НЕ ЗДЕСЬ. Второй вызов ниже даёт ту же
* страницу, разговаривающую с ОДНИМ ХОЗЯИНОМ ЯДРА (`?core=…`, поднимается
* `npm run core:host`). Тогда книга у всех буквально одна: ширина столбца у
* соседа появится потому, что это ТА ЖЕ ширина. Никакого «слияния» при этом не
* появляется и появиться не может — сливать нечего.
*/
export const book = (core: string | null = null, pace = 0): ContractTransport =>
pace > 0 ? slowRoad(bookOnFastRoad(core), pace) : bookOnFastRoad(core);
const bookOnFastRoad = (core: string | null): ContractTransport =>
core === null
? createMockTransport({
cellAt: (row, col) => (col === 0 ? `строка ${row + 1}` : `${(row + 1) * (col + 1)}`),
})
: /* КНИГА ЖИВЁТ У ХОЗЯИНА, а не в рамке: каждая рамка получает СВОЮ транспорт
к ОДНОМУ адресу — ровно так же, как две вкладки на двух машинах. */
createServiceHostTransport({ endpoint: core });
/**
* ДОРОГА ДО КНИГИ, КОТОРАЯ ОТВЕЧАЕТ НЕ БЫСТРЕЕ, ЧЕМ ЗА `ms`.
*
* ЭТО СВОЙСТВО ДОРОГИ, А НЕ ПРИТВОРСТВО ПРОДУКТА. Порог совместности продукт
* меряет сам (`pacedTransport` в `@f1/contract`) — по той дороге, которая ему
* досталась. На заглушке эта дорога стоит доли миллисекунды, поэтому НА
* ВИТРИНЕ порога не увидеть вовсе: показывать нечего. А на живом ядре с книгой
* владельца то же чтение окна стоит 5 953 мс (замер
* `tools/bench-collab-threshold.mjs`), и продукт обязан это ПРОИЗНЕСТИ.
*
* Медленная дорога и есть тот случай, только достижимый без 33 мегабайт и
* собранного ядра: хозяин страницы объявляет задержку (`?pace=400`), продукт
* её ИЗМЕРЯЕТ и говорит правду о том, что измерил. Ни одно число на экране при
* этом не выдумано — выдумана только медленная дорога, и выдумана она ЗДЕСЬ,
* на стороне хозяина страницы, а не в продукте.
*/
const slowRoad = (inner: ContractTransport, ms: number): ContractTransport => ({
send: async (request) => {
await new Promise((resolve) => setTimeout(resolve, ms));
return inner.send(request);
},
/* Общность документа и вторая сторона разговора — свойства ДОРОГИ, и
задержка их не меняет: полоса рядом говорит о них своими строками. */
share: inner.share,
/* ИМЯ КНИГИ — ТОЖЕ СВОЙСТВО ДОРОГИ, И ОНО ПРОБРАСЫВАЕТСЯ ГЕТТЕРОМ, а не
копией: у транспорта оно ПОЯВЛЯЕТСЯ и МЕНЯЕТСЯ, когда книгу открывают
(`subsetId` из `@f1/contract`). Обёртка, потерявшая его, ломает
ровно сочетание `?core=…&pace=…`: живое ядро отвечает на запрос без имени
книги отказом, то есть медленная дорога превращала бы витрину в пустой
лист. На заглушке это незаметно — она имени не требует, и потому дефект
здесь молчит ровно до первого живого ядра. */
get subsetId() {
return inner.subsetId;
},
...(inner.subscribe ? { subscribe: inner.subscribe.bind(inner) } : {}),
});

Сторона хозяина страницы: что приложение отвечает листу и что от него получает.

src/host.ts
import { applyLocaleAttributes, resolveLocale, setLocale, type Locale } from "@f1/i18n";
import { DEFAULT_CORE_HOST_ENDPOINT } from "@f1/contract";
import { CO_EDIT_DEFAULT, CO_EDIT_MODES, DEFAULT_PRESENCE_ENDPOINT, type CoEditMode } from "@f1/presence";
/**
* ЧТО ХОЗЯИН СТРАНИЦЫ ДАЁТ ЛИСТУ — тема, язык и КАНАЛ ПРИСУТСТВИЯ.
*
* У встраивающего это уже есть своё: тему он знает из настроек, язык — из
* профиля пользователя, а канал выбирает потому, что у него и документ, и
* учётная запись, и служба (если она есть). Здесь всё читается из адреса
* (`?theme=dark&locale=ar&link=socket`) ровно затем, чтобы на любое состояние
* можно было дать ссылку и чтобы его проверял обход: настройка, доступная
* только щелчком, недоступна ни ссылке, ни автоматической проверке.
*
* ПРИЗНАКИ СТАВЯТСЯ НА КОРЕНЬ ДОКУМЕНТА — потому что это делает ХОЗЯИН, а не
* лист. Лист читает тему оттуда, где стоит (`:host-context`), а язык получает
* СВОЙСТВОМ сборки: два листа на одной странице вправе говорить на разном.
*
* НАПРАВЛЕНИЕ ПИСЬМА — ИНТЕРФЕЙСА, А НЕ ДОКУМЕНТА. `dir="rtl"` зеркалит хром
* страницы; «Лист справа налево» из Excel — свойство КНИГИ, и сетка от
* арабского языка интерфейса не переворачивается. Это не пропуск, а решение
* (`locale.ts`).
*/
export type LinkKind = "memory" | "broadcast" | "socket";
const KINDS: readonly LinkKind[] = ["memory", "broadcast", "socket"];
export interface HostAsks {
readonly theme: "light" | "dark";
readonly locale: Locale;
readonly link: LinkKind;
readonly relay: string;
/**
* Кем открыта эта вкладка: `anna`, `boris` или `null` — обеими сразу.
*
* Нужно ровно для сокета: две вкладки, в каждой обе рамки, дали бы двух
* участников с ОДНИМ идентификатором на каждого — ретранслятор честно
* раздал бы вести, а список показал бы человеку его самого.
*/
readonly only: "anna" | "boris" | null;
/**
* АДРЕС ХОЗЯИНА ЯДРА — ИМ И ОБЪЯВЛЯЕТСЯ ОБЩНОСТЬ КНИГИ.
*
* `null` — хозяин не назван, и тогда у каждой рамки СВОЯ книга-заглушка. Это
* тот случай, который со стороны выглядит ошибкой: сосед виден, а его ширина
* столбца — нет.
*
* ПОЧЕМУ ОБЩНОСТЬ ОБЪЯВЛЯЕТСЯ АДРЕСОМ, А НЕ КЛЮЧОМ, КАК У ПРИСУТСТВИЯ. У
* присутствия ключ нужен потому, что ретранслятор один, а документов у него
* много: комнату надо назвать. У хозяина ядра наоборот — он держит ОДИН живой
* сеанс, то есть одну книгу, и адрес хозяина и есть имя этой книги. Второй
* ключ поверх адреса был бы ложью: назвав разные ключи одному хозяину, люди
* всё равно правили бы одну книгу. Появится хозяин с несколькими книгами —
* появится ключ, и придёт он оттуда же, откуда адрес: ОТ ХОЗЯИНА СТРАНИЦЫ.
*/
readonly core: string | null;
/**
* СКОЛЬКО ДЕРЖИТ ДОРОГА ДО КНИГИ, мс (порог совместности
* говорится на экране).
*
* Ноль — дорога как есть. Больше нуля — МЕДЛЕННАЯ дорога: ровно то, что
* случается на живом ядре с большой книгой (замер: чтение окна на книге
* владельца 5 953 мс), только достижимое без 33 мегабайт и собранного ядра.
* Продукт задержку не изображает, а ИЗМЕРЯЕТ — и произносит то, что измерил.
*/
readonly pace: number;
/**
* РЕЖИМ СОВМЕСТНОЙ ПРАВКИ — `?mode=strict`.
*
* Читается из адреса по той же причине, что тема и язык: настройка, доступная
* только щелчком, недостижима ни ссылке, ни обходу, а строгий режим обязан
* проверяться ДВУМЯ изолированными вкладками — то есть двумя адресами.
*
* Умолчание берётся у модели (`CO_EDIT_DEFAULT`), а не пишется здесь: второй
* владелец умолчания означал бы, что витрина показывает не тот режим, который
* у продукта по умолчанию.
*/
readonly mode: CoEditMode;
}
export function hostAsks(): HostAsks {
const params = new URLSearchParams(location.search);
const theme = params.get("theme") === "dark" ? "dark" : "light";
const locale = resolveLocale(params.get("locale") ?? undefined, "ru");
const asked = params.get("link");
/* Неизвестное слово молча не превращается в сокет: тихая подстановка канала
означала бы, что подпись на экране говорит не о том канале, который есть. */
const link = KINDS.find((kind) => kind === asked) ?? "memory";
const only = params.get("as");
/* `?core` без значения означает «возьми умолчание», а отсутствие признака —
«хозяина нет вовсе». Разница видна на экране: во втором случае продукт
говорит, что документ у каждого свой. */
const askedCore = params.get("core");
/* Отрицательное и нечисловое молча не превращается в задержку: дорога, о
которой сказано неразборчиво, остаётся такой, какая есть. */
const askedPace = Number(params.get("pace"));
/* Неизвестное слово молча не превращается в строгий режим — тем же доводом,
что и у канала: подпись на экране говорила бы не о том режиме, который
есть, а обещание защиты дороже обещания канала. */
const askedMode = params.get("mode");
document.documentElement.setAttribute("data-theme", theme);
/* Язык — один на процесс: словарь модульный, и ставит его хозяин страницы. */
setLocale(locale);
applyLocaleAttributes(document.documentElement, locale);
return {
theme,
locale,
link,
relay: params.get("relay") ?? DEFAULT_PRESENCE_ENDPOINT,
only: only === "anna" || only === "boris" ? only : null,
core: askedCore === null ? null : askedCore === "" ? DEFAULT_CORE_HOST_ENDPOINT : askedCore,
pace: Number.isFinite(askedPace) && askedPace > 0 ? askedPace : 0,
mode: CO_EDIT_MODES.find((kind) => kind === askedMode) ?? CO_EDIT_DEFAULT,
};
}

Оформление страницы вокруг листа.

src/styles.css
/* ЛИСТ СТИЛЕЙ НАБОРА КОМПОНЕНТОВ — ЕДИНСТВЕННАЯ СТРОКА, КОТОРУЮ НЕЛЬЗЯ ЗАБЫТЬ.
Собственное оформление лист приносит в теневые корни сам, но утилиты
раскладки, которыми он держит свои органы, компилируются ЭТИМ листом. Без
него страница не просто выглядит иначе — вкладка ЗАВИСАЕТ (бесконечный цикл
замера у сетки без размеров). У потребителя вне репозитория это
`@fastoffices/formula-kit` из его node_modules. */
@import "@fastoffices/formula-kit"; /* см. примечание выше */
html,
body {
height: 100%;
margin: 0;
}

Из каких пакетов собран пример.

package.json
{
"name": "f1-example-presence",
"private": true,
"type": "module",
"description": "Двое в одном документе: чужой курсор с именем в реальном времени — и честная подпись о том, кого мы на самом деле видим.",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"@f1/preset-sheets-core": "*",
"@f1/presence": "*",
"@f1/contract": "*",
"@f1/i18n": "*"
},
"devDependencies": {
"vite": "^7.0.0"
}
}

Ассистент документации

Ответ собран из документации и может быть неточным — сверяйтесь с источниками.