Refine glass material and environment controls
This commit is contained in:
+148
-40
@@ -47,6 +47,28 @@ const accents: Array<{ label: string; value: RgbTuple; hex: string }> = [
|
||||
{ label: "Magenta", value: [215, 70, 255], hex: "#d746ff" },
|
||||
];
|
||||
|
||||
type MaterialDraft = {
|
||||
panelHex: string;
|
||||
panelOpacity: number;
|
||||
fieldHex: string;
|
||||
fieldOpacity: number;
|
||||
};
|
||||
|
||||
const materialDefaults: Record<NodedcTheme, MaterialDraft> = {
|
||||
dark: {
|
||||
panelHex: "#16161a",
|
||||
panelOpacity: 68,
|
||||
fieldHex: "#101016",
|
||||
fieldOpacity: 78,
|
||||
},
|
||||
light: {
|
||||
panelHex: "#ffffff",
|
||||
panelOpacity: 62,
|
||||
fieldHex: "#ffffff",
|
||||
fieldOpacity: 72,
|
||||
},
|
||||
};
|
||||
|
||||
type ShareRole = "viewer" | "editor" | "admin";
|
||||
|
||||
const shareRoleOptions = [
|
||||
@@ -208,6 +230,11 @@ function Preview({ title, note, children, className }: {
|
||||
export function CatalogApp() {
|
||||
const [theme, setTheme] = useState<NodedcTheme>("dark");
|
||||
const [accentHex, setAccentHex] = useState("#ff2f92");
|
||||
const [materialByTheme, setMaterialByTheme] = useState<Record<NodedcTheme, MaterialDraft>>(() => ({
|
||||
dark: { ...materialDefaults.dark },
|
||||
light: { ...materialDefaults.light },
|
||||
}));
|
||||
const [savedEnvironmentControl, setSavedEnvironmentControl] = useState<string | null>(null);
|
||||
const workspace = useApplicationWorkspace<CatalogSection>();
|
||||
const { navigationOpen: guidelineOpen, activeView: activeSection, contentExpanded: panelExpanded } = workspace;
|
||||
const [selectedStatus, setSelectedStatus] = useState<(typeof selectOptions)[number]["value"]>("active");
|
||||
@@ -219,7 +246,9 @@ export function CatalogApp() {
|
||||
const [lightColor, setLightColor] = useState("#ff2f92");
|
||||
const [connectionColor, setConnectionColor] = useState("#404040");
|
||||
const [fillColor, setFillColor] = useState("#ff6caf");
|
||||
const [fillOpacity, setFillOpacity] = useState(100);
|
||||
const [strokeColor, setStrokeColor] = useState("#2b2b36");
|
||||
const [strokeOpacity, setStrokeOpacity] = useState(100);
|
||||
const [windowOpen, setWindowOpen] = useState(false);
|
||||
const [sidePanelOpen, setSidePanelOpen] = useState(false);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
@@ -253,10 +282,22 @@ export function CatalogApp() {
|
||||
const stageMediaSrc = mediaSource === "url" && externalMediaSrc ? externalMediaSrc : fileMediaSrc;
|
||||
|
||||
const accent = useMemo(() => rgbFromHex(accentHex) ?? accents[0].value, [accentHex]);
|
||||
const material = materialByTheme[theme];
|
||||
const panelMaterial = useMemo(() => rgbFromHex(material.panelHex) ?? rgbFromHex(materialDefaults[theme].panelHex)!, [material.panelHex, theme]);
|
||||
const fieldMaterial = useMemo(() => rgbFromHex(material.fieldHex) ?? rgbFromHex(materialDefaults[theme].fieldHex)!, [material.fieldHex, theme]);
|
||||
|
||||
useEffect(() => {
|
||||
applyNodedcTheme(document.documentElement, { theme, accent });
|
||||
}, [accent, theme]);
|
||||
applyNodedcTheme(document.documentElement, {
|
||||
theme,
|
||||
accent,
|
||||
material: {
|
||||
panel: panelMaterial,
|
||||
panelOpacity: material.panelOpacity / 100,
|
||||
field: fieldMaterial,
|
||||
fieldOpacity: material.fieldOpacity / 100,
|
||||
},
|
||||
});
|
||||
}, [accent, fieldMaterial, material.fieldOpacity, material.panelOpacity, panelMaterial, theme]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (mediaObjectUrlRef.current) URL.revokeObjectURL(mediaObjectUrlRef.current);
|
||||
@@ -296,16 +337,89 @@ export function CatalogApp() {
|
||||
|
||||
const closeGuideline = workspace.closeNavigation;
|
||||
|
||||
const updateMaterial = (changes: Partial<MaterialDraft>) => {
|
||||
setMaterialByTheme((current) => ({
|
||||
...current,
|
||||
[theme]: { ...current[theme], ...changes },
|
||||
}));
|
||||
};
|
||||
|
||||
const saveEnvironmentLayout = (control: string) => {
|
||||
const draft = {
|
||||
theme,
|
||||
accentHex,
|
||||
material,
|
||||
environment: {
|
||||
lightColor,
|
||||
brightness,
|
||||
glowDistance,
|
||||
connectionType,
|
||||
connectionColor,
|
||||
usePortColors,
|
||||
fillColor,
|
||||
fillOpacity,
|
||||
strokeColor,
|
||||
strokeOpacity,
|
||||
},
|
||||
};
|
||||
try {
|
||||
window.localStorage.setItem("nodedc-design-guideline-draft", JSON.stringify(draft));
|
||||
} catch {
|
||||
// The preview remains useful in restricted browser contexts even without local persistence.
|
||||
}
|
||||
setSavedEnvironmentControl(control);
|
||||
};
|
||||
|
||||
const savedAction = (control: string) => (
|
||||
<IconButton label={`Сохранить «${control}» в локальный layout`} onClick={() => saveEnvironmentLayout(control)}>
|
||||
<Icon name="save" />
|
||||
</IconButton>
|
||||
);
|
||||
|
||||
const environmentSections = [
|
||||
{
|
||||
id: "application-material",
|
||||
label: "Материал приложения",
|
||||
description: "live draft",
|
||||
tone: "accent" as const,
|
||||
content: (
|
||||
<>
|
||||
<ControlRow label="Акцент" action={savedAction("Акцент")}>
|
||||
<ColorField label="Акцент приложения" value={accentHex} onChange={setAccentHex} />
|
||||
</ControlRow>
|
||||
<ControlRow label="Плашки" action={savedAction("Плашки")}>
|
||||
<ColorField label="Цвет плашек" value={material.panelHex} onChange={(panelHex) => updateMaterial({ panelHex })} />
|
||||
</ControlRow>
|
||||
<ControlRow label="Прозрачность плашки" action={savedAction("Прозрачность плашки")}>
|
||||
<RangeControl label="Прозрачность плашки" showLabel={false} value={material.panelOpacity} min={18} max={96} formatValue={(value) => `${value}%`} onChange={(panelOpacity) => updateMaterial({ panelOpacity })} />
|
||||
</ControlRow>
|
||||
<ControlRow label="Поля ввода" action={savedAction("Поля ввода")}>
|
||||
<ColorField label="Цвет полей ввода" value={material.fieldHex} onChange={(fieldHex) => updateMaterial({ fieldHex })} />
|
||||
</ControlRow>
|
||||
<ControlRow label="Прозрачность полей" action={savedAction("Прозрачность полей")}>
|
||||
<RangeControl label="Прозрачность полей" showLabel={false} value={material.fieldOpacity} min={18} max={100} formatValue={(value) => `${value}%`} onChange={(fieldOpacity) => updateMaterial({ fieldOpacity })} />
|
||||
</ControlRow>
|
||||
<p className="catalog-environment-draft" aria-live="polite">
|
||||
{savedEnvironmentControl ? `«${savedEnvironmentControl}» сохранён в локальный layout. Сервер и MCP не используются.` : "Дискета сохраняет текущий параметр в локальный layout браузера."}
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "illumination",
|
||||
label: "Подсветка",
|
||||
tone: "accent" as const,
|
||||
content: (
|
||||
<>
|
||||
<ControlRow label="Цвет"><ColorField value={lightColor} onChange={setLightColor} /></ControlRow>
|
||||
<RangeControl label="Яркость" value={brightness} min={0} max={100} formatValue={(value) => `${value}%`} onChange={setBrightness} />
|
||||
<RangeControl label="Дистанция свечения" value={glowDistance} min={0} max={200} formatValue={(value) => `${value}%`} onChange={setGlowDistance} />
|
||||
<ControlRow label="Цвет" action={savedAction("Цвет подсветки")}><ColorField value={lightColor} onChange={setLightColor} /></ControlRow>
|
||||
<div className="catalog-environment-with-action">
|
||||
<RangeControl label="Яркость" value={brightness} min={0} max={100} formatValue={(value) => `${value}%`} onChange={setBrightness} />
|
||||
{savedAction("Яркость")}
|
||||
</div>
|
||||
<div className="catalog-environment-with-action">
|
||||
<RangeControl label="Дистанция свечения" value={glowDistance} min={0} max={200} formatValue={(value) => `${value}%`} onChange={setGlowDistance} />
|
||||
{savedAction("Дистанция свечения")}
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
@@ -315,7 +429,7 @@ export function CatalogApp() {
|
||||
tone: "accent" as const,
|
||||
content: (
|
||||
<>
|
||||
<ControlRow label="Связи по умолчанию" layout="stack">
|
||||
<ControlRow label="Связи по умолчанию" action={savedAction("Связи по умолчанию")}>
|
||||
<Select
|
||||
variant="split"
|
||||
label="Тип связи"
|
||||
@@ -328,9 +442,12 @@ export function CatalogApp() {
|
||||
onChange={setConnectionType}
|
||||
/>
|
||||
</ControlRow>
|
||||
<Checker checked={usePortColors} label="Использовать цвет портов" onChange={setUsePortColors} />
|
||||
<ControlRow label="Цвет связей по умолчанию"><ColorField value={connectionColor} onChange={setConnectionColor} /></ControlRow>
|
||||
<ControlRow label="Применить ко всем"><Button variant="accent" shape="pill" width="full">Применить</Button></ControlRow>
|
||||
<div className="catalog-environment-with-action">
|
||||
<Checker checked={usePortColors} label="Использовать цвет портов" onChange={setUsePortColors} />
|
||||
{savedAction("Использовать цвет портов")}
|
||||
</div>
|
||||
<ControlRow label="Цвет связей по умолчанию" action={savedAction("Цвет связей")}><ColorField value={connectionColor} onChange={setConnectionColor} /></ControlRow>
|
||||
<ControlRow label="Применить ко всем"><Button variant="primary" shape="pill" width="full">Применить</Button></ControlRow>
|
||||
</>
|
||||
),
|
||||
},
|
||||
@@ -340,10 +457,16 @@ export function CatalogApp() {
|
||||
tone: "accent" as const,
|
||||
content: (
|
||||
<>
|
||||
<ControlRow label="Заливка"><ColorField value={fillColor} onChange={setFillColor} /></ControlRow>
|
||||
<RangeControl label="Прозрачность" value={100} min={0} max={100} formatValue={(value) => `${value}%`} onChange={() => undefined} />
|
||||
<ControlRow label="Обводка"><ColorField value={strokeColor} onChange={setStrokeColor} /></ControlRow>
|
||||
<RangeControl label="Прозрачность" value={100} min={0} max={100} formatValue={(value) => `${value}%`} onChange={() => undefined} />
|
||||
<ControlRow label="Заливка" action={savedAction("Заливка портов")}><ColorField value={fillColor} onChange={setFillColor} /></ControlRow>
|
||||
<div className="catalog-environment-with-action">
|
||||
<RangeControl label="Прозрачность заливки" value={fillOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={setFillOpacity} />
|
||||
{savedAction("Прозрачность заливки")}
|
||||
</div>
|
||||
<ControlRow label="Обводка" action={savedAction("Обводка портов")}><ColorField value={strokeColor} onChange={setStrokeColor} /></ControlRow>
|
||||
<div className="catalog-environment-with-action">
|
||||
<RangeControl label="Прозрачность обводки" value={strokeOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={setStrokeOpacity} />
|
||||
{savedAction("Прозрачность обводки")}
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
@@ -356,25 +479,10 @@ export function CatalogApp() {
|
||||
<div className="catalog-grid catalog-grid--foundation">
|
||||
<Preview title="Тема приложения" note={theme === "dark" ? "dark glass" : "light glass"}>
|
||||
<div className="catalog-theme-row">
|
||||
<Button variant={theme === "dark" ? "accent" : "secondary"} shape="pill" onClick={() => setTheme("dark")}>Dark</Button>
|
||||
<Button variant={theme === "light" ? "accent" : "secondary"} shape="pill" onClick={() => setTheme("light")}>Light</Button>
|
||||
<Button variant={theme === "dark" ? "primary" : "secondary"} shape="pill" onClick={() => setTheme("dark")}>Dark</Button>
|
||||
<Button variant={theme === "light" ? "primary" : "secondary"} shape="pill" onClick={() => setTheme("light")}>Light</Button>
|
||||
</div>
|
||||
<div className="catalog-swatches">
|
||||
{accents.map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
className="catalog-swatch"
|
||||
data-active={item.hex.toLowerCase() === accentHex.toLowerCase() ? "true" : undefined}
|
||||
style={{ "--swatch": item.hex } as CSSProperties}
|
||||
onClick={() => setAccentHex(item.hex)}
|
||||
>
|
||||
<span />{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<ColorField label="Акцент приложения" value={accentHex} onChange={setAccentHex} />
|
||||
<p className="catalog-preview__explanation">Акцент сразу перекрашивает environment-controls, активные действия, заливку range, file action и состояния окон. Геометрия и semantic-status при этом не меняются.</p>
|
||||
<p className="catalog-preview__explanation">Тема задаёт нейтральный каркас. Акцент, плашки, поля и их прозрачность настраиваются живым Environment control — там же сохраняется локальный draft.</p>
|
||||
</Preview>
|
||||
<Preview title="Иерархия стекла" note="default / strong / soft">
|
||||
<div className="catalog-surface-stack">
|
||||
@@ -398,7 +506,7 @@ export function CatalogApp() {
|
||||
<div className="catalog-grid">
|
||||
<Preview title="Действия" note="общая геометрия">
|
||||
<div className="catalog-inline catalog-inline--wrap">
|
||||
<Button variant="accent" shape="pill" icon={<Icon name="save" />}>Сохранить</Button>
|
||||
<Button variant="primary" shape="pill" icon={<Icon name="save" />}>Сохранить</Button>
|
||||
<Button icon={<Icon name="refresh" />}>Обновить</Button>
|
||||
<Button variant="ghost" icon={<Icon name="external" />}>Открыть</Button>
|
||||
<Button variant="danger" icon={<Icon name="trash" />}>Удалить</Button>
|
||||
@@ -448,9 +556,9 @@ export function CatalogApp() {
|
||||
<div className="catalog-environment-demo">
|
||||
<GlassSurface tone="strong" radius="panel" className="catalog-environment-card">
|
||||
<div className="catalog-environment-title"><strong>Настройки окружения</strong><span>ENGINE / новый канон</span></div>
|
||||
<Inspector sections={environmentSections} defaultOpen={["illumination", "connections"]} />
|
||||
<Inspector sections={environmentSections} defaultOpen={["application-material"]} />
|
||||
</GlassSurface>
|
||||
<p className="catalog-preview__explanation">Здесь собраны все контролы, которые нужны новым приложениям: color palette, range, checker, split-select, accent action и раскрывающиеся sections. Отдельной вкладки Inspector в каталоге больше нет.</p>
|
||||
<p className="catalog-preview__explanation">Environment controls — единый референс Engine: palette, range, checker, split-select и раскрывающиеся sections. Верхняя секция меняет настоящий material приложения; отдельной вкладки Inspector больше нет.</p>
|
||||
</div>
|
||||
</Preview>
|
||||
<Preview title="Статусы операций" note="не цвет приложения" className="catalog-preview--wide">
|
||||
@@ -505,7 +613,7 @@ export function CatalogApp() {
|
||||
<Preview title="Управляемые окна" note="center / modeless end">
|
||||
<p className="catalog-preview__explanation">Обычное окно блокирует фон и удерживает фокус. Правый инспектор не затемняет, не блокирует stage и не превращается в modal.</p>
|
||||
<div className="catalog-inline catalog-inline--wrap">
|
||||
<Button variant="accent" shape="pill" icon={<Icon name="apps" />} onClick={() => setWindowOpen(true)}>Открыть окно</Button>
|
||||
<Button variant="primary" shape="pill" icon={<Icon name="apps" />} onClick={() => setWindowOpen(true)}>Открыть окно</Button>
|
||||
<Button shape="pill" icon={<Icon name="panel" />} onClick={() => setSidePanelOpen(true)}>Открыть боковую панель</Button>
|
||||
</div>
|
||||
</Preview>
|
||||
@@ -524,7 +632,7 @@ export function CatalogApp() {
|
||||
<div className="catalog-grid">
|
||||
<Preview title="Создание" note="форма адаптируется по ширине">
|
||||
<p className="catalog-preview__explanation">Поля переходят в одну колонку на узком viewport; footer остаётся видимым, прокручивается только тело окна.</p>
|
||||
<Button variant="accent" shape="pill" icon={<Icon name="plus" />} onClick={() => setCreateModalOpen(true)}>Создать проект</Button>
|
||||
<Button variant="primary" shape="pill" icon={<Icon name="plus" />} onClick={() => setCreateModalOpen(true)}>Создать проект</Button>
|
||||
</Preview>
|
||||
<Preview title="Подтверждение удаления" note="async-safe">
|
||||
<p className="catalog-preview__explanation">Destructive-цвет появляется у подтверждения, а не окрашивает всё окно заранее.</p>
|
||||
@@ -532,7 +640,7 @@ export function CatalogApp() {
|
||||
</Preview>
|
||||
<Preview title="Workflow sharing" note="ENGINE / access modal">
|
||||
<p className="catalog-preview__explanation">Участники, роли, приглашение и immutable-владелец. API доступа остаётся в Engine.</p>
|
||||
<Button variant="accent" shape="pill" icon={<Icon name="users" />} onClick={() => setShareAccessOpen(true)}>Поделиться графом</Button>
|
||||
<Button variant="primary" shape="pill" icon={<Icon name="users" />} onClick={() => setShareAccessOpen(true)}>Поделиться графом</Button>
|
||||
</Preview>
|
||||
<Preview title="Ссылка на модель" note="BIM / share link">
|
||||
<p className="catalog-preview__explanation">Read-only ссылка, copy-state и единая механика focus/Escape/backdrop.</p>
|
||||
@@ -676,7 +784,7 @@ export function CatalogApp() {
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setWindowOpen(false)}>Отмена</Button>
|
||||
<WindowFooterActions><Button variant="accent" shape="pill" onClick={() => setWindowOpen(false)}>Сохранить</Button></WindowFooterActions>
|
||||
<WindowFooterActions><Button variant="primary" shape="pill" onClick={() => setWindowOpen(false)}>Сохранить</Button></WindowFooterActions>
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -709,7 +817,7 @@ export function CatalogApp() {
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setCreateModalOpen(false)}>Отмена</Button>
|
||||
<WindowFooterActions><Button variant="accent" shape="pill" icon={<Icon name="plus" />} onClick={() => setCreateModalOpen(false)}>Создать</Button></WindowFooterActions>
|
||||
<WindowFooterActions><Button variant="primary" shape="pill" icon={<Icon name="plus" />} onClick={() => setCreateModalOpen(false)}>Создать</Button></WindowFooterActions>
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -805,7 +913,7 @@ export function CatalogApp() {
|
||||
</Button>
|
||||
<WindowFooterActions>
|
||||
<Button onClick={() => setDetailOpen(false)}>Отмена</Button>
|
||||
<Button variant="accent" onClick={() => setDetailOpen(false)}>Сохранить</Button>
|
||||
<Button variant="primary" onClick={() => setDetailOpen(false)}>Сохранить</Button>
|
||||
</WindowFooterActions>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -315,7 +315,7 @@ textarea {
|
||||
gap: clamp(1rem, 4vw, 4rem);
|
||||
border-radius: var(--nodedc-radius-card);
|
||||
background:
|
||||
radial-gradient(circle at 18% 24%, rgba(var(--nodedc-accent-rgb), 0.13), transparent 22rem),
|
||||
radial-gradient(circle at 18% 24%, rgb(var(--nodedc-accent-rgb) / 0.13), transparent 22rem),
|
||||
color-mix(in srgb, var(--nodedc-canvas-soft) 86%, transparent);
|
||||
padding: clamp(1rem, 3vw, 2rem);
|
||||
}
|
||||
@@ -336,6 +336,21 @@ textarea {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.catalog-environment-draft {
|
||||
margin: 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.catalog-environment-with-action {
|
||||
display: grid;
|
||||
width: min(var(--nodedc-inspector-inner-width), 100%);
|
||||
grid-template-columns: minmax(0, 1fr) var(--nodedc-control-height);
|
||||
align-items: center;
|
||||
gap: var(--nodedc-space-2);
|
||||
}
|
||||
|
||||
.catalog-icon-catalog {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
|
||||
Reference in New Issue
Block a user