feat(tokens): app-local non-player tokens with session moves and UI polish

Add token library/placements, keep play-time moves for the session, lock presentation interactions, and fix export/import modal layout plus freeform trap label.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-27 11:04:12 +08:00
parent bdeb64e356
commit f270812219
44 changed files with 2617 additions and 341 deletions
+7 -8
View File
@@ -1,3 +1,10 @@
.buttonHost {
display: inline-flex;
vertical-align: middle;
flex: 0 0 auto;
max-width: 100%;
}
.button {
height: 34px;
padding: 0 14px;
@@ -89,11 +96,3 @@
outline: none;
color: inherit;
}
select.input {
padding-right: 28px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M2.5 4.25L6 7.75L9.5 4.25' stroke='rgba(255,255,255,0.72)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
background-size: 12px 12px;
}
+117
View File
@@ -0,0 +1,117 @@
.root {
width: 100%;
min-width: 0;
max-width: 100%;
}
.trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
min-width: 0;
min-height: 34px;
box-sizing: border-box;
padding: 0 10px 0 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--stroke);
background: var(--color-overlay-dark-3);
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
outline: none;
}
.trigger:hover:not(:disabled) {
border-color: var(--stroke-2);
background: var(--color-panel-2);
}
.trigger:focus-visible {
border-color: var(--accent-border);
box-shadow: 0 0 0 2px var(--accent-fill-soft);
}
.trigger:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.triggerOpen {
border-color: var(--accent-border);
background: var(--color-panel-2);
}
.value {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.placeholder {
color: var(--text2);
}
.chevron {
flex: 0 0 auto;
width: 12px;
height: 12px;
opacity: 0.72;
transition: transform 0.15s ease;
}
.chevronOpen {
transform: rotate(180deg);
}
.menu {
position: fixed;
z-index: calc(var(--z-modal) + 10);
max-height: min(280px, 50vh);
overflow: auto;
padding: 6px;
border-radius: var(--radius-sm);
border: 1px solid var(--stroke-2);
background: var(--color-surface-menu);
box-shadow: var(--shadow-menu);
backdrop-filter: var(--backdrop-blur-surface);
-webkit-backdrop-filter: var(--backdrop-blur-surface);
}
.option {
display: block;
width: 100%;
text-align: left;
padding: 8px 10px;
border: 0;
border-radius: var(--radius-xs);
background: transparent;
color: var(--text0);
font: inherit;
font-size: var(--text-sm);
cursor: pointer;
}
.option:hover:not(:disabled),
.optionActive:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
}
.optionSelected {
background: var(--accent-fill-soft-2);
color: var(--text-on-accent);
}
.optionSelected:hover:not(:disabled),
.optionSelected.optionActive:not(:disabled) {
background: var(--accent-fill-soft);
}
.option:disabled {
cursor: not-allowed;
opacity: 0.4;
}
+271
View File
@@ -0,0 +1,271 @@
import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import styles from './Select.module.css';
export type SelectOption = {
value: string;
label: string;
disabled?: boolean;
};
export type SelectProps = {
value: string;
options: readonly SelectOption[];
onChange: (value: string) => void;
disabled?: boolean;
ariaLabel?: string;
/** Доп. класс на кнопку-триггер (ширина и т.п.). */
className?: string;
placeholder?: string;
};
type MenuPos = { left: number; top: number; width: number; maxHeight: number };
function Chevron({ open }: { open: boolean }) {
return (
<svg
className={[styles.chevron, open ? styles.chevronOpen : ''].filter(Boolean).join(' ')}
viewBox="0 0 12 12"
aria-hidden
>
<path
d="M2.5 4.25L6 7.75L9.5 4.25"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</svg>
);
}
export function Select({
value,
options,
onChange,
disabled = false,
ariaLabel,
className,
placeholder = '—',
}: SelectProps) {
const listId = useId();
const triggerRef = useRef<HTMLButtonElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const [open, setOpen] = useState(false);
const [menuPos, setMenuPos] = useState<MenuPos | null>(null);
const [activeIndex, setActiveIndex] = useState(0);
const selected = useMemo(() => options.find((o) => o.value === value) ?? null, [options, value]);
const enabledIndexes = useMemo(
() => options.map((o, i) => (o.disabled ? -1 : i)).filter((i) => i >= 0),
[options],
);
const close = useCallback(() => {
setOpen(false);
setMenuPos(null);
}, []);
const layoutMenu = useCallback(() => {
const trigger = triggerRef.current;
if (!trigger) return;
const r = trigger.getBoundingClientRect();
const pad = 8;
const gap = 4;
const preferredMax = Math.min(280, window.innerHeight * 0.5);
const spaceBelow = window.innerHeight - r.bottom - pad;
const spaceAbove = r.top - pad;
const placeBelow = spaceBelow >= 120 || spaceBelow >= spaceAbove;
const maxHeight = Math.max(96, Math.min(preferredMax, placeBelow ? spaceBelow - gap : spaceAbove - gap));
const top = placeBelow ? r.bottom + gap : Math.max(pad, r.top - gap - maxHeight);
setMenuPos({
left: Math.max(pad, Math.min(r.left, window.innerWidth - r.width - pad)),
top,
width: r.width,
maxHeight,
});
}, []);
const openMenu = useCallback(() => {
if (disabled) return;
const selectedIdx = options.findIndex((o) => o.value === value && !o.disabled);
const fallback = enabledIndexes[0] ?? 0;
setActiveIndex(selectedIdx >= 0 ? selectedIdx : fallback);
setOpen(true);
}, [disabled, enabledIndexes, options, value]);
useLayoutEffect(() => {
if (!open) return;
layoutMenu();
}, [layoutMenu, open, options.length]);
useEffect(() => {
if (!open) return;
const onWin = () => layoutMenu();
window.addEventListener('resize', onWin);
window.addEventListener('scroll', onWin, true);
return () => {
window.removeEventListener('resize', onWin);
window.removeEventListener('scroll', onWin, true);
};
}, [layoutMenu, open]);
useEffect(() => {
if (!open) return;
const onPointerDown = (e: PointerEvent) => {
const t = e.target;
if (!(t instanceof Node)) return;
if (triggerRef.current?.contains(t)) return;
if (menuRef.current?.contains(t)) return;
close();
};
window.addEventListener('pointerdown', onPointerDown, true);
return () => window.removeEventListener('pointerdown', onPointerDown, true);
}, [close, open]);
useEffect(() => {
if (!open) return;
const el = menuRef.current?.querySelector<HTMLElement>(`[data-select-index="${String(activeIndex)}"]`);
el?.scrollIntoView({ block: 'nearest' });
}, [activeIndex, open]);
const moveActive = (dir: 1 | -1) => {
if (enabledIndexes.length === 0) return;
const pos = enabledIndexes.indexOf(activeIndex);
const nextPos =
pos < 0
? dir === 1
? 0
: enabledIndexes.length - 1
: (pos + dir + enabledIndexes.length) % enabledIndexes.length;
setActiveIndex(enabledIndexes[nextPos]!);
};
const commitIndex = (index: number) => {
const opt = options[index];
if (!opt || opt.disabled) return;
onChange(opt.value);
close();
triggerRef.current?.focus();
};
const onTriggerKeyDown = (e: React.KeyboardEvent) => {
if (disabled) return;
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (!open) {
openMenu();
return;
}
if (e.key === 'ArrowDown') moveActive(1);
else if (e.key === 'ArrowUp') moveActive(-1);
else if (e.key === 'Enter' || e.key === ' ') commitIndex(activeIndex);
} else if (e.key === 'Escape' && open) {
e.preventDefault();
close();
}
};
const onMenuKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
moveActive(1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
moveActive(-1);
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
commitIndex(activeIndex);
} else if (e.key === 'Escape') {
e.preventDefault();
close();
triggerRef.current?.focus();
} else if (e.key === 'Tab') {
close();
}
};
const triggerClass = [
styles.trigger,
open ? styles.triggerOpen : '',
className ?? '',
]
.filter(Boolean)
.join(' ');
const menu =
open && menuPos && typeof document !== 'undefined'
? createPortal(
<div
ref={menuRef}
id={listId}
role="listbox"
className={styles.menu}
style={{
left: menuPos.left,
top: menuPos.top,
width: menuPos.width,
maxHeight: menuPos.maxHeight,
}}
onKeyDown={onMenuKeyDown}
>
{options.map((opt, index) => {
const selectedOpt = opt.value === value;
const active = index === activeIndex;
return (
<button
key={`${opt.value}::${String(index)}`}
type="button"
role="option"
data-select-index={index}
aria-selected={selectedOpt}
disabled={opt.disabled}
className={[
styles.option,
selectedOpt ? styles.optionSelected : '',
active ? styles.optionActive : '',
]
.filter(Boolean)
.join(' ')}
onMouseEnter={() => {
if (!opt.disabled) setActiveIndex(index);
}}
onClick={() => commitIndex(index)}
>
{opt.label}
</button>
);
})}
</div>,
document.body,
)
: null;
return (
<div className={styles.root}>
<button
ref={triggerRef}
type="button"
className={triggerClass}
disabled={disabled}
aria-label={ariaLabel}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={open ? listId : undefined}
onClick={() => {
if (open) close();
else openMenu();
}}
onKeyDown={onTriggerKeyDown}
>
<span className={[styles.value, selected ? '' : styles.placeholder].filter(Boolean).join(' ')}>
{selected?.label ?? placeholder}
</span>
<Chevron open={open} />
</button>
{menu}
</div>
);
}
+12 -5
View File
@@ -3,6 +3,8 @@ import { createPortal } from 'react-dom';
import styles from './Controls.module.css';
export { Select, type SelectOption, type SelectProps } from './Select';
type ButtonProps = {
children: React.ReactNode;
onClick?: () => void;
@@ -94,22 +96,23 @@ export function Button({
);
// Disabled buttons don't receive mouse events — host span keeps tooltip usable.
// Single DOM root (not Fragment) so flex/grid parents don't mis-place the button.
if (disabled && title) {
return (
<>
<span className={styles.buttonHost}>
<span ref={hostRef} className={styles.disabledTipHost} onMouseEnter={showTip} onMouseLeave={hideTip}>
{button}
</span>
{tip}
</>
</span>
);
}
return (
<>
<span className={styles.buttonHost}>
{button}
{tip}
</>
</span>
);
}
@@ -117,15 +120,19 @@ type InputProps = {
value: string;
placeholder?: string;
onChange: (v: string) => void;
autoFocus?: boolean;
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
};
export function Input({ value, placeholder, onChange }: InputProps) {
export function Input({ value, placeholder, onChange, autoFocus, onKeyDown }: InputProps) {
return (
<input
className={styles.input}
value={value}
placeholder={placeholder}
autoFocus={autoFocus}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
/>
);
}