feat: bilingual support (FA/EN), IRANSans typography, RTL layout, and interactive product detail pages

This commit is contained in:
s.esmaeili
2026-10-04 07:11:52 +03:30
commit a001fa5d5f
302 changed files with 35491 additions and 0 deletions
+316
View File
@@ -0,0 +1,316 @@
import type { JsonObject } from "./shared";
import type { CSSProperties, InputHTMLAttributes, ReactElement, ReactNode, RefObject, TextareaHTMLAttributes } from "react";
/** A person, the same shape as the ClickUp kit's `User`. */
interface User {
kind: "user";
id: string;
name: string;
/** Link to the person's profile; empty when the host does not name one. */
url: string;
/** Avatar URL. */
image?: string;
}
/** Where a pointer is, as `<Cursors>` sends it in `presence.cursor` and `cursor:move`: fractions of its region or, with `anchor`, of that element's box, the region it is in and the sender's clock in ms. Over a surface seen through a camera, `space: "page"` and that surface's own units instead. */
type CursorPoint = JsonObject & {
x: number;
y: number;
t?: number;
region?: string;
space?: "page";
/** `#id` of the nearest ancestor with a token id, or `""` for the region itself, then `tag:n` steps down to the element. */
anchor?: string[];
dx?: number;
dy?: number;
};
/** One of the six emoji, sent from where the pointer was. */
type Emote = CursorPoint & {
emoji: string;
};
/** The point two hands meet at, and whose hand the sender's meets. */
type HighFive = CursorPoint & {
with: string;
};
/** The sender's wave starting or ending, in the region it started in. */
interface Wave {
region?: string;
on: boolean;
}
/** Text someone selected inside a `<Cursors>` area, as `presence.selection`: the path of each end from that area (child indices, then the offset in the last node) and a hash of the text, so it is drawn only over the same text. */
type TextSelection = JsonObject & {
from: number[];
to: number[];
hash: string;
region?: string;
};
/** What an `id` must look like to travel: generated ids pass, names and titles do not. */
declare const TOKEN: RegExp;
declare const MAX_STEPS = 24;
declare function fraction(value: number): number;
/** The region key a point or a wave carries, when it has one. */
declare function regionOf(region: string | undefined): JsonObject;
/** The units a `<Cursors>` reads and writes: its region's fractions, or with a `space`, the surface's own units tagged `"page"`. */
type Units = "page" | undefined;
/** `value` when it is a cursor point in `region` and `units` as `<Cursors>` sends one; an anchor this page cannot walk is left out. */
declare function pointOf(value: unknown, region: string | undefined, units?: Units): CursorPoint | undefined;
/** `payload` when it is a `cursor:emote` in `region` and `units`. */
declare function emoteOf(payload: unknown, region: string | undefined, units: Units): Emote | undefined;
/** `payload` when it is a `cursor:high-five` in `region` and `units`. */
declare function highFiveOf(payload: unknown, region: string | undefined, units: Units): HighFive | undefined;
/** `payload` when it is a `cursor:wave` in `region`. */
declare function waveOf(payload: unknown, region: string | undefined): Wave | undefined;
/** The presence's cursor while it is in `region` and `units`. */
declare function cursorOf(presence: JsonObject, region: string | undefined, units?: Units): CursorPoint | undefined;
/** The presence's chat message while they are typing one. */
declare function messageOf(presence: JsonObject): string | undefined;
/** The presence's selection when it has the shape `<Cursors>` writes and is in `region`. */
declare function selectionOf(presence: JsonObject, region: string | undefined): TextSelection | undefined;
/** My three presence keys as I have them now; every publish sends all three and leaves the rest of my presence alone. */
declare const mine: {
cursor?: CursorPoint;
message?: string;
selection?: TextSelection;
};
/** Notes where my pointer is without publishing it; the next publish carries it. */
declare function moved(cursor: CursorPoint): void;
/** Sets the given keys of mine, `undefined` taking one away, and publishes all of them. */
declare function publish(update?: Partial<typeof mine>): void;
/** A position in px of the surface a cursor is drawn on. */
interface Point {
x: number;
y: number;
}
/** The surface a cursor is drawn on: the units of the points it reads and writes, and where such a point falls on it, in px, as it lays out now. */
interface Space {
readonly units: Units;
resolve(sample: CursorPoint): Point;
/** Whether the viewer looks at the surface through a camera of their own; a target that moved since last frame is then followed at once rather than eased. */
readonly camera?: boolean;
}
/**
* Replays one person's cursor samples on the sender's clock, behind by a
* delay that grows when a sample lands late and shrinks every two seconds.
* Between samples it moves straight, and whatever the target moves that the
* pointer did not becomes an offset that decays instead of a jump; through a
* camera the cursor stays on the artwork.
*/
declare class Glide {
private samples;
/** Local clock minus sender clock, the smallest seen: the least delayed delivery. */
private offset;
private delay;
/** The smallest lead the buffer had when a sample landed since the last review. */
private lead;
private reviewedAt;
private lastNow;
/** What was aimed at last frame, to tell a shift of this page from the pointer's own motion. */
private aimed;
/** The offset still to decay and its velocity. */
private residual;
private residualVelocity;
/** Adds a sample as it lands; without a sender clock its arrival time stands in. Starts over when the sender's clock goes backwards or a sample lands more than the longest delay sooner than any before. */
push(point: CursorPoint, arrivedAt: number): void;
/** Where to draw at local time `now` on `space`, or undefined before the first sample. */
draw(now: number, space: Space): Point | undefined;
/** Forgets the offset being decayed. */
rest(): void;
/** The hops of the anchor crossings between sender times `from` and `to`: how far each lies from where the pointer's own step would have carried the cursor. */
private hopped;
/** The position at sender time `T`: linear between the samples around it, inside one anchor along the fractions of its box, across anchors by the pointer's own step from the earlier sample. */
private place;
/** The samples around sender time `T`: `p1` at or before it, `p2` after it when there is one, and how far between them `T` lies. */
private locate;
private playhead;
/** Grows the delay by how far behind the playhead this sample landed, if it did, and reviews the delay every two seconds. */
private adapt;
}
/**
* Replays one collaborator's cursor on a surface, gliding (or jumping under
* reduced motion) and fading `IDLE_MS` after the last sample; each frame's
* position goes to `onFrame`, undefined once faded. `stop()` only stops: the
* host removes what it drew.
*/
declare class Replay {
private readonly space;
private readonly onFrame;
private readonly glide;
private frame;
private idle;
private last;
private faded;
/** The newest sample, drawn as is under reduced motion. */
private rest;
constructor(space: Space, onFrame: (at: Point | undefined) => void);
follow(point: CursorPoint): void;
/** Shows the cursor where it was, draws it every frame, and starts the `IDLE_MS` to fading it. */
wake(): void;
stop(): void;
private readonly fade;
/** Draws where the samples fall on the surface now, each frame while the cursor shows. */
private readonly draw;
}
/**
* How a `<Cursors>` over a surface each viewer looks at through their own
* camera (an `<svg viewBox>`, a `<canvas>`, a panned and zoomed layer) maps
* the viewport to the units positions are shared in, and back; both are pure
* and read as the camera stands at the moment of the call.
*/
export interface CursorSpace {
/** A point of my viewport as the units I publish: the chart, page or world coordinate under it. */
toContent(clientX: number, clientY: number): Point;
/** A published point as client px, where my camera shows it now. */
toClient(x: number, y: number): Point;
}
/** A surface seen through a camera: a wire point falls where the host's camera shows its units now, in px of the overlay. */
declare class CameraSpace implements Space {
private readonly overlay;
private readonly space;
readonly units: Units;
readonly camera = true;
constructor(overlay: () => HTMLElement, space: () => CursorSpace);
resolve(point: CursorPoint): Point;
}
declare function finite(at: Point): Point | undefined;
export interface Cursor {
user: User;
/** Their colour, the same as their `Avatar`. */
color: string;
presence: JsonObject;
/** The default arrow and name, to decorate or to return for some people. */
arrow: ReactNode;
}
export interface CursorsProps {
/** Press `/` to type a message everyone sees at your cursor; Escape closes it. On by default. */
chat?: boolean;
/** Press `1`–`6` to react with an emoji at your cursor (`E` shows which key is which), wiggle the pointer to wave, and rest it on someone's cursor to high five; a hint of the keys shows once. On by default. */
emotes?: boolean;
/** A selector for the element positions are fractions of, inside the element this covers; that element itself by default. A fixed-aspect board centred in a responsive page keeps cursors exact on every screen, and pointers beside it show beside it. */
frame?: string;
/** Names the space this covers, like `slide-${index}`: cursors written here carry it, and only cursors from the same region are drawn. Lets several `<Cursors>` share a page. */
region?: string;
/** Draws one collaborator's cursor in place of the default arrow and name. */
render?: (cursor: Cursor) => ReactNode;
/** Text you select here is highlighted in your colour for everyone else, where their page shows the same text. On by default. */
selection?: boolean;
/** For a canvas each viewer pans and zooms for themselves (an `<svg viewBox>`, a `<canvas>`, a transformed layer): `toContent(clientX, clientY)` maps my pointer to the units positions are shared in and `toClient(x, y)` maps a shared point back to my screen, so everyone's cursors ride the artwork through my camera. Positions then carry no anchor and `frame` is ignored. */
space?: CursorSpace;
}
/**
* Shows everyone else's pointer over the parent element (give it
* `className="relative"`), or over the whole page when no parent is
* positioned; never your own. Each cursor is an arrow with the person's name
* in their colour that follows their movement a moment behind and fades when
* they stop, pointing at the same element on every screen size and scroll
* offset, at the same fraction of `frame`, or on the same artwork through
* each viewer's own camera with a `space`.
*/
export declare function Cursors({ chat, emotes, frame, region, render, selection, space, }: CursorsProps): ReactElement;
/** An open hand in `color`, its wrist on the parent's origin, and the `Puppet` that animates it as the ref; `arrow` is the arrow the hand stands in for. */
declare const HandSlot: import("react").ForwardRefExoticComponent<{
arrow?: RefObject<Element>;
color: string;
} & import("react").RefAttributes<Puppet>>;
/** The hand's pose from the pointer's horizontal velocity: it swings with the motion, leans into it and squashes on each turn. */
declare class Swing {
private angle;
private velocity;
private lastX;
private lastT;
/** A position sample in px at time `t` in ms. */
push(x: number, t: number): void;
/** The transform for this frame; the velocity decays while no sample lands. */
pose(now: number): string;
reset(): void;
}
/** When the hands meet inside the clap clip. */
declare const IMPACT_MS = 440;
/** Drives one cursor's hand and arrow: a small hand beside the tip while a high five is on offer, the hand in place of the arrow while its owner waves, and the high-five clip. */
declare class Puppet {
private readonly slot;
private readonly svg;
private readonly arrow;
private readonly swing;
private readonly running;
private state;
/** The side a high five on offer wants the small hand on, kept through a clap. */
private armed;
private frame;
private bob;
private watchdog;
constructor(slot: RefObject<HTMLDivElement>, svg: RefObject<SVGSVGElement>, arrow: RefObject<Element>);
/** Shows a small hand beside the tip while a high five is on offer, on `side` of it: the side this hand will come in from. */
arm(on: boolean, side?: 1 | -1): void;
wave(on: boolean): void;
/** A position sample of the hand's owner, in px at time `t`, for the swing; while waving it also keeps the hand from timing out. */
track(x: number, t: number): void;
/** Runs the high-five clip: the hand comes in from `side` of the meeting point `to` (relative to the tip, in px), meets the other, recoils and comes back; resolves when it is back. */
clap(to: Point, side: 1 | -1): Promise<void>;
stop(): void;
/** Ends the bob, the swing and its watchdog. */
private still;
private readonly draw;
/** Names the hand's state on its slot, and the glow on the arrow while a high five is on offer. */
private mark;
/** Animates the slot to `to`: from `from` while the hand is hidden, else from wherever its last animation left it. */
private enter;
/** Animates `element` through `frames`, from wherever its last animation left it. */
private run;
}
/** The clap's impact, centred on the parent's origin: dots in the two colours flying out and a ring widening; `onEnd` when they are gone. */
declare function Burst({ colors, onEnd, }: {
colors: string[];
onEnd: () => void;
}): ReactElement;
/** One person's cursor as drawn, in em of the parent: the arrow with its tip at the origin, name and message in a pill of `colors` beside it, and the hand the `Puppet` ref drives. */
declare const CursorGlyph: import("react").ForwardRefExoticComponent<{
colors: {
backgroundColor: string;
color: string;
};
message: string | undefined;
user: User;
} & import("react").RefAttributes<Puppet>>;
/**
* The arrow `Cursors` draws: its tip is the top-left corner, its fill the
* current text colour, its edge white. Size it with `className`, `h-5` say,
* to build a cursor of your own.
*/
export declare function CursorArrow({ className, style, }: {
className?: string;
style?: CSSProperties;
}): ReactElement;
/** A full-size box translated to `at`, px of the surface, or left for the host to place and fade when `at` is not given; its content sits at the box's corner. */
declare const Layer: import("react").ForwardRefExoticComponent<{
at?: Point;
children: ReactNode;
} & import("react").RefAttributes<HTMLDivElement>>;
/** What sits at my pointer while `at` is given, and for LINGER_MS after so it can pop out; `children` is told when it is leaving. */
declare function AtPointer({ at, children, }: {
at: Point | undefined;
children: (leaving: boolean) => ReactNode;
}): ReactElement | null;
export interface SharedTextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
/** The document path, like `cards/${card.id}`. */
doc: string;
field: string;
}
/**
* A textarea for a `sharedText()` field; read-only for any other field.
* Edits apply to the shared text at once (IME input at composition end),
* `onChange` fires afterwards with the resulting value, the caret survives
* remote edits, and `value` and `defaultValue` are ignored.
*/
export declare function SharedTextarea({ doc, field, ...attributes }: SharedTextareaProps): ReactElement;
export interface SharedInputProps extends InputHTMLAttributes<HTMLInputElement> {
doc: string;
field: string;
}
/** A one-line input for a `sharedText()` field; read-only for any other field. `value`, `defaultValue` and `type` are ignored: it is always `type="text"`, the one input type whose caret the binding can follow. */
export declare function SharedInput({ doc, field, ...attributes }: SharedInputProps): ReactElement;
+206
View File
@@ -0,0 +1,206 @@
/** What a field can hold. Store dates as ISO strings or numbers. */
export type JsonValue = string | number | boolean | null | JsonValue[] | JsonObject;
export interface JsonObject {
[field: string]: JsonValue;
}
/** A document's fields plus its `id`, the last path segment; read-only. */
export interface DocData extends JsonObject {
id: string;
}
export type FieldValue = {
"#delete": true;
} | {
"#text": string;
} | {
"#opaque": {
y: "xml" | "map";
of: string;
};
} | {
"#increment": number;
} | {
"#union": JsonValue[];
} | {
"#remove": JsonValue[];
};
/**
* The fields to write, each key on its own: a dotted key like `"votes.u1"`
* sets one nested value, a plain object replaces the whole nested object, a
* marker (`deleteField()`, `sharedText()`, `increment()`, `arrayUnion()`,
* `arrayRemove()` or another kit's) creates or changes that kind of field,
* and `undefined` is skipped. `id` is ignored, so an item can be spread back
* in; only the fields the spread changed are written.
*/
export interface Fields {
[field: string]: JsonValue | FieldValue | undefined;
}
/** Removes a field: `update({ [`votes.${uid}`]: deleteField() })`. */
export declare function deleteField(): FieldValue;
/** Creates a text everyone can type in together: `add({ body: sharedText() })`. Edit it with `SharedTextarea` or `SharedInput`; the field reads and writes as a string. */
export declare function sharedText(initial?: string): FieldValue;
/** Adds to a number without losing anyone else's additions: `update({ likes: increment(1) })`; negative subtracts. The field reads as a number, and writing a plain number to it adjusts it by the difference. */
export declare function increment(by: number): FieldValue;
/** Adds values to an array, each once, without losing anyone else's: `update({ tags: arrayUnion("bug") })`, or `arrayUnion(...tags)` for several. The field reads as an array in the order values were added, and writing a plain array to it adds and removes the difference. */
export declare function arrayUnion(...values: JsonValue[]): FieldValue;
/** Removes values from an array: `update({ tags: arrayRemove("bug") })`. */
export declare function arrayRemove(...values: JsonValue[]): FieldValue;
export interface SharedDoc {
/** False until the document is created with `set`; `update` throws before that. */
exists: boolean;
data: DocData;
/** Creates or replaces the whole document. */
set(fields: Fields): void;
/** Merges into the document; throws if it does not exist. */
update(fields: Fields | ((current: DocData) => Fields)): void;
/** Deletes the document and its subcollections; nothing happens if it does not exist. */
delete(): void;
}
/**
* One document at a path like `game/state` or `cards/k3f9`; `defaults` fill
* in missing top-level fields and are never written. A null path gives empty
* data and methods that do nothing; suspends until the shared state has
* loaded.
*/
export declare function useSharedDoc(path: string | null | undefined, defaults?: Fields): SharedDoc;
export interface SharedCollection {
/** The child documents, in order, as of this render; `shared.collection(path).items` is always current. */
items: readonly DocData[];
/** Adds a document at the end and returns its id. Pass `id` in the fields to choose it. */
add(fields: Fields): string;
/** Creates or replaces the document with this `id`; returns the id. */
set(id: string, fields: Fields): string;
/** Merges into the item; throws if the id is unknown. */
update(id: string, fields: Fields | ((current: DocData) => Fields)): void;
/** Deletes the item and its subcollections; nothing happens if the id is unknown. */
delete(id: string): void;
/** Moves an item to an index, or before or after another item; `items` returns the new order. Throws if either id is unknown. */
move(id: string, to: number | {
before: string;
} | {
after: string;
}): void;
}
/**
* The documents at a collection path like `cards` or `cards/k3f9/steps`, in
* order, and the methods to change them. A null path gives no items and
* methods that do nothing; suspends until the shared state has loaded.
*/
export declare function useSharedCollection(path: string | null | undefined): SharedCollection;
/** One person viewing the artifact right now: who they are and their presence. */
export interface Collaborator {
user: User;
/** Their presence; `{}` until they set one. */
presence: JsonObject;
}
/**
* This viewer: `user` is `clickup.viewer`, `presence` is what everyone else
* sees of them live, `{}` until set, never saved.
*/
export declare function useMe(): Collaborator & {
/** Sets what everyone else sees of you, like a `useState` setter: pass a new value or a function of the current one. Spread the current one to keep the keys other components write: `cursor`, `message` and `selection` from `Cursors`, `slide` from a slides `Deck`. */
setPresence(presence: JsonObject | ((current: JsonObject) => JsonObject)): void;
};
/**
* Everyone else viewing the artifact right now, one entry per person. With
* `select`, only what it returns, re-rendering when that changes:
* `useCollaborators((all) => all.filter((c) => c.presence.editing === id))`.
*/
export declare function useCollaborators<T = readonly Collaborator[]>(select?: (collaborators: readonly Collaborator[]) => T): T;
/**
* Runs `listener` for each `name` broadcast another person sends while
* mounted, with the payload and who sent it:
* `useBroadcast("buzz", (payload, from) => flash(from.name))`. Broadcasts
* are for what happens, documents for what must survive; a listener runs on
* every other page, so a write in it lands once per viewer.
*/
export declare function useBroadcast(name: string, listener: (payload: JsonValue, from: User) => void): void;
/**
* Undo and redo this viewer's own changes; all writes from one event handler
* are one step. While `canEdit` is false, both do nothing and both flags are
* false.
*/
export declare function useUndo(): {
undo(): void;
redo(): void;
canUndo: boolean;
canRedo: boolean;
};
export interface SharedStatus {
/** `connecting` until the shared state has loaded, and again while reconnecting; `disconnected` on a host without collaboration; otherwise the host's connection state. */
connection: "connecting" | "connected" | "disconnected";
/** False while loading, in read-only rooms and in outdated builds. Writes are then ignored. */
canEdit: boolean;
/** True when a newer build of this artifact is live. Ask the viewer to reload. */
outdated: boolean;
}
/** Whether the shared state is connected, whether you can edit it, and whether a newer build is live. */
export declare function useSharedStatus(): SharedStatus;
/**
* Shared state outside React: the same documents, collections, presence,
* broadcasts, undo and status as the hooks, always current. Use it in a draw
* loop or in an event listener set up outside a component; in render, use
* the hooks.
*/
export declare const shared: {
/** The document at `path`, as `useSharedDoc` gives it; `data` and `exists` read the current state. */
doc(path: string | null | undefined, defaults?: Fields): SharedDoc;
/** The collection at `path`, as `useSharedCollection` gives it; `items` reads the current state. */
collection(path: string | null | undefined): SharedCollection;
/** This viewer, as `useMe` gives them. */
readonly me: Collaborator & {
setPresence(presence: JsonObject | ((current: JsonObject) => JsonObject)): void;
};
readonly collaborators: readonly Collaborator[];
/** Sends `name` with `payload` to everyone else viewing the artifact right now; never saved, never replayed to anyone who arrives later. */
broadcast(name: string, payload?: JsonValue): void;
/** Calls `listener` with each `name` broadcast another person sends, as `useBroadcast` does. Returns a function that stops the calls. */
onBroadcast(name: string, listener: (payload: JsonValue, from: User) => void): () => void;
readonly status: SharedStatus;
undo(): void;
redo(): void;
readonly canUndo: boolean;
readonly canRedo: boolean;
/** Calls `listener` after every change to documents, presence, status or history. Returns a function that stops the calls. */
subscribe(listener: () => void): () => void;
/** Resolves once the shared state has loaded; until then writes are ignored. */
ready(): Promise<void>;
};
/** A person, the same shape as the ClickUp kit's `User`. */
export interface User {
kind: "user";
id: string;
name: string;
/** Link to the person's profile; empty when the host does not name one. */
url: string;
/** Avatar URL. */
image?: string;
}
/** A stored value of mine outside React: `get()`, `set()` and `remove()`. */
export interface MyState<T> {
/** The stored value, or `initial` while nothing is stored. */
get(): T;
/** Replaces the value; a function receives the current one, like a `useState` setter. */
set(next: T | ((current: T) => T)): void;
/** Deletes the key; `get()` returns `initial` again. */
remove(): void;
}
/**
* A value of mine that survives a reload, `useState` with a key:
* `const [tab, setTab] = useMyState("tab", "board")`, stored per viewer per
* artifact as JSON, up to 4,000 characters per key (`set` throws past that).
* Suspends until the stored value has loaded, so the first render never shows
* `initial` over a stored value; a key mounted later needs its own
* `<Suspense>`.
*/
export declare function useMyState<T>(key: string, initial: T): [T, (next: T | ((current: T) => T)) => void];
/**
* The same value outside React, always current: `myState("tab", "board").get()`
* in a draw loop or event listener, `.set(v)` to write it, `.remove()` to
* delete the key. Starts loading the value when called.
*/
export declare function myState<T>(key: string, initial: T): MyState<T>;
export declare function myState<T>(key: string): MyState<T | undefined>;
/** A value the kit keeps for this viewer under a `#` key, which authors are refused; resolves once the stored value has loaded. */
declare function kitState<T>(key: `#${string}`, initial: T): Promise<MyState<T>>;