Release glass material and favicon pipeline v0.6.0

This commit is contained in:
DCCONSTRUCTIONS 2026-07-11 12:19:44 +03:00
parent 6b42d913ea
commit d873427fe2
28 changed files with 550 additions and 53 deletions

View File

@ -4,6 +4,10 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#050506" /> <meta name="theme-color" content="#050506" />
<link rel="icon" href="/favicon/favicon.ico" sizes="any" />
<link rel="icon" type="image/svg+xml" href="/favicon/icon-adaptive.svg" />
<link rel="apple-touch-icon" href="/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/favicon/manifest.webmanifest.json" />
<title>NODE.DC UI Catalog</title> <title>NODE.DC UI Catalog</title>
</head> </head>
<body> <body>
@ -11,4 +15,3 @@
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

View File

@ -1,6 +1,6 @@
{ {
"name": "@nodedc/ui-catalog", "name": "@nodedc/ui-catalog",
"version": "0.5.1", "version": "0.6.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@ -9,8 +9,8 @@
"typecheck": "tsc -b --pretty false" "typecheck": "tsc -b --pretty false"
}, },
"dependencies": { "dependencies": {
"@nodedc/ui-core": "0.5.1", "@nodedc/ui-core": "0.6.0",
"@nodedc/ui-react": "0.5.1", "@nodedc/ui-react": "0.6.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0" "react-dom": "^19.1.0"
}, },

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -0,0 +1,13 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<style>
.icon-fav-01 { fill: #333334; } /* Тёмный цвет для светлого таб-бара */
@media (prefers-color-scheme: dark) {
.icon-fav-01 { fill: #FEFEFE; } /* Светлый цвет для тёмного таб-бара */
}
@media (prefers-color-scheme: light) {
.icon-fav-01 { fill: #000000; } /* Тёмный цвет для светлого таб-бара (для явности) */
}
</style>
<path class="icon-fav-01" d="M17.8738 15.9159L16.0002 18.9718L14.1267 15.9159H17.8738ZM23.6307 12.7864H8.36914L15.9999 25.2308L23.6307 12.7864Z"/>
<path class="icon-fav-01" d="M10.9793 19.4872L6.67269 11.5918H25.5344L21.2281 19.4872H25.0581L31.2614 8H0.738281L7.16244 19.4872H10.9793Z"/>
</svg>

After

Width:  |  Height:  |  Size: 852 B

View File

@ -0,0 +1,29 @@
{
"theme_color": "#eeeff4",
"background_color": "#eeeff4",
"display": "browser",
"scope": "/",
"start_url": "/",
"icons": [
{
"src": "icon-adaptive.svg",
"type": "image/svg+xml",
"sizes": "any"
},
{
"src": "icon-192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "icon-512.png",
"type": "image/png",
"sizes": "512x512"
},
{
"src": "apple-touch-icon.png",
"type": "image/png",
"sizes": "180x180"
}
]
}

View File

@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { applyNodedcTheme, type NodedcTheme, type RgbTuple } from "@nodedc/ui-core"; import { applyGlassMaterial, applyNodedcTheme, defaultGlassMaterial, type GlassMaterialSettings, type NodedcTheme, type RgbTuple } from "@nodedc/ui-core";
import { import {
AdminNavigationPanel, AdminNavigationPanel,
AppHeader, AppHeader,
@ -13,6 +13,7 @@ import {
Dropdown, Dropdown,
FieldFrame, FieldFrame,
GlassSurface, GlassSurface,
GlassMaterialSurface,
HeaderAvatar, HeaderAvatar,
HeaderNavigation, HeaderNavigation,
HeaderProfile, HeaderProfile,
@ -39,8 +40,9 @@ import {
type ShareAccessMember, type ShareAccessMember,
type ToolbarPlacement, type ToolbarPlacement,
} from "@nodedc/ui-react"; } from "@nodedc/ui-react";
import { applyFaviconAssets, createIcoBlob, generateFavicons, type FaviconAssetUrls, type GeneratedFavicon } from "./favicon.js";
type CatalogSection = "controls" | "media" | "modals" | "icons"; type CatalogSection = "controls" | "media" | "glass" | "modals" | "icons";
const accents: Array<{ label: string; value: RgbTuple; hex: string }> = [ const accents: Array<{ label: string; value: RgbTuple; hex: string }> = [
{ label: "NODE.DC", value: [255, 47, 146], hex: "#ff2f92" }, { label: "NODE.DC", value: [255, 47, 146], hex: "#ff2f92" },
@ -83,7 +85,10 @@ interface StoredLayout {
logoUrl?: string; logoUrl?: string;
logoFileName?: string; logoFileName?: string;
logoFileSrc?: string; logoFileSrc?: string;
faviconFileName?: string;
faviconAssets?: FaviconAssetUrls;
}; };
glass?: GlassMaterialSettings;
toolbar?: { toolbar?: {
placement?: ToolbarPlacement; placement?: ToolbarPlacement;
background?: string; background?: string;
@ -140,14 +145,20 @@ const sectionDefinitions: Record<CatalogSection, { eyebrow: string; title: strin
description: "Общий CMS/Launcher-контракт файла, URL, превью и нейтральных карточек настроек.", description: "Общий CMS/Launcher-контракт файла, URL, превью и нейтральных карточек настроек.",
icon: "image", icon: "image",
}, },
glass: {
eyebrow: "03 / GLASSY MATERIAL",
title: "Glassy material",
description: "Единый материал только для модальных окон и перемещаемого Inspector.",
icon: "apps",
},
modals: { modals: {
eyebrow: "03 / MODALS", eyebrow: "04 / MODALS",
title: "Модалки", title: "Модалки",
description: "Полная карта modal-паттернов Launcher, нового Engine и BIM Viewer.", description: "Полная карта modal-паттернов Launcher, нового Engine и BIM Viewer.",
icon: "clipboard", icon: "clipboard",
}, },
icons: { icons: {
eyebrow: "04 / ICONS", eyebrow: "05 / ICONS",
title: "Иконки", title: "Иконки",
description: "Канонический общий набор по Launcher, SEO, BIM Viewer и новым участкам Engine.", description: "Канонический общий набор по Launcher, SEO, BIM Viewer и новым участкам Engine.",
icon: "grid", icon: "grid",
@ -306,6 +317,17 @@ export function CatalogApp() {
const [logoError, setLogoError] = useState(""); const [logoError, setLogoError] = useState("");
const logoObjectUrlRef = useRef<string | null>(null); const logoObjectUrlRef = useRef<string | null>(null);
const pendingLogoFileRef = useRef<File | null>(null); const pendingLogoFileRef = useRef<File | null>(null);
const [glassMaterial, setGlassMaterial] = useState<GlassMaterialSettings>({ ...defaultGlassMaterial });
const [faviconFileName, setFaviconFileName] = useState("icon-adaptive.svg");
const [faviconError, setFaviconError] = useState("");
const [generatedFavicons, setGeneratedFavicons] = useState<GeneratedFavicon[]>([]);
const [faviconAssets, setFaviconAssets] = useState<FaviconAssetUrls>({
ico: "/favicon/favicon.ico",
apple: "/favicon/apple-touch-icon.png",
icon192: "/favicon/icon-192.png",
icon512: "/favicon/icon-512.png",
});
const pendingFaviconIcoRef = useRef<Blob | null>(null);
const [toolbarPlacement, setToolbarPlacement] = useState<ToolbarPlacement>("bottom"); const [toolbarPlacement, setToolbarPlacement] = useState<ToolbarPlacement>("bottom");
const [toolbarBg, setToolbarBg] = useState("#111115"); const [toolbarBg, setToolbarBg] = useState("#111115");
const [toolbarBorder, setToolbarBorder] = useState("#111117"); const [toolbarBorder, setToolbarBorder] = useState("#111117");
@ -350,6 +372,14 @@ export function CatalogApp() {
document.documentElement.style.setProperty("--nodedc-canvas-soft", material.nestedHex); document.documentElement.style.setProperty("--nodedc-canvas-soft", material.nestedHex);
}, [accent, fieldMaterial, material.fieldOpacity, material.nestedHex, material.panelOpacity, panelMaterial, theme]); }, [accent, fieldMaterial, material.fieldOpacity, material.nestedHex, material.panelOpacity, panelMaterial, theme]);
useEffect(() => {
applyGlassMaterial(document.documentElement, glassMaterial);
}, [glassMaterial]);
useEffect(() => {
applyFaviconAssets(faviconAssets);
}, [faviconAssets]);
useEffect(() => { useEffect(() => {
let active = true; let active = true;
fetch("/api/layout", { cache: "no-store" }) fetch("/api/layout", { cache: "no-store" })
@ -386,7 +416,10 @@ export function CatalogApp() {
if (typeof media.logoUrl === "string") setLogoUrl(media.logoUrl); if (typeof media.logoUrl === "string") setLogoUrl(media.logoUrl);
if (media.logoFileName) setLogoFileName(media.logoFileName); if (media.logoFileName) setLogoFileName(media.logoFileName);
if (media.logoFileSrc) setLogoFileSrc(media.logoFileSrc); if (media.logoFileSrc) setLogoFileSrc(media.logoFileSrc);
if (media.faviconFileName) setFaviconFileName(media.faviconFileName);
if (media.faviconAssets) setFaviconAssets(media.faviconAssets);
} }
if (stored.glass) setGlassMaterial({ ...defaultGlassMaterial, ...stored.glass });
const toolbar = stored.toolbar; const toolbar = stored.toolbar;
if (toolbar) { if (toolbar) {
if (toolbar.placement === "left" || toolbar.placement === "right" || toolbar.placement === "bottom") setToolbarPlacement(toolbar.placement); if (toolbar.placement === "left" || toolbar.placement === "right" || toolbar.placement === "bottom") setToolbarPlacement(toolbar.placement);
@ -412,7 +445,8 @@ export function CatalogApp() {
useEffect(() => () => { useEffect(() => () => {
if (mediaObjectUrlRef.current) URL.revokeObjectURL(mediaObjectUrlRef.current); if (mediaObjectUrlRef.current) URL.revokeObjectURL(mediaObjectUrlRef.current);
if (logoObjectUrlRef.current) URL.revokeObjectURL(logoObjectUrlRef.current); if (logoObjectUrlRef.current) URL.revokeObjectURL(logoObjectUrlRef.current);
}, []); generatedFavicons.forEach((item) => URL.revokeObjectURL(item.previewUrl));
}, [generatedFavicons]);
const handleStageMediaFile = (file?: File) => { const handleStageMediaFile = (file?: File) => {
if (!file) return; if (!file) return;
@ -449,6 +483,27 @@ export function CatalogApp() {
setLogoError(""); setLogoError("");
}; };
const handleFaviconFile = async (file?: File) => {
if (!file) return;
const isImage = /^(image\/(png|jpeg|webp))$/i.test(file.type) || /\.(png|jpe?g|webp)$/i.test(file.name);
if (!isImage) {
setFaviconError("Для генератора нужен PNG, JPG или WEBP.");
return;
}
try {
const next = await generateFavicons(file);
setGeneratedFavicons((current) => {
current.forEach((item) => URL.revokeObjectURL(item.previewUrl));
return next;
});
pendingFaviconIcoRef.current = await createIcoBlob(next);
setFaviconFileName(file.name);
setFaviconError("");
} catch {
setFaviconError("Не удалось прочитать исходное изображение.");
}
};
const inviteShareMember = () => { const inviteShareMember = () => {
const email = shareEmail.trim().toLowerCase(); const email = shareEmail.trim().toLowerCase();
if (!email) return; if (!email) return;
@ -506,6 +561,28 @@ export function CatalogApp() {
setLogoFileName(uploaded.fileName); setLogoFileName(uploaded.fileName);
pendingLogoFileRef.current = null; pendingLogoFileRef.current = null;
} }
let persistedFaviconAssets = faviconAssets;
if (generatedFavicons.length && pendingFaviconIcoRef.current) {
const upload = async (blob: Blob, name: string) => {
const response = await fetch("/api/layout/media", {
method: "PUT",
headers: { "content-type": blob.type || "application/octet-stream", "x-file-name": encodeURIComponent(name) },
body: blob,
});
if (!response.ok) throw new Error("favicon_upload_failed");
return await response.json() as { url: string };
};
const bySize = new Map(generatedFavicons.map((item) => [item.size, item]));
const [ico, apple, icon192, icon512] = await Promise.all([
upload(pendingFaviconIcoRef.current, "favicon.ico"),
upload(bySize.get(180)!.blob, "apple-touch-icon.png"),
upload(bySize.get(192)!.blob, "icon-192.png"),
upload(bySize.get(512)!.blob, "icon-512.png"),
]);
persistedFaviconAssets = { ico: ico.url, apple: apple.url, icon192: icon192.url, icon512: icon512.url };
setFaviconAssets(persistedFaviconAssets);
pendingFaviconIcoRef.current = null;
}
const draft: StoredLayout = { const draft: StoredLayout = {
theme, theme,
accentHex, accentHex,
@ -532,7 +609,10 @@ export function CatalogApp() {
logoUrl, logoUrl,
logoFileName, logoFileName,
logoFileSrc: persistedLogoFileSrc, logoFileSrc: persistedLogoFileSrc,
faviconFileName,
faviconAssets: persistedFaviconAssets,
}, },
glass: glassMaterial,
toolbar: { toolbar: {
placement: toolbarPlacement, placement: toolbarPlacement,
background: toolbarBg, background: toolbarBg,
@ -785,6 +865,53 @@ export function CatalogApp() {
onFileChange={handleLogoFile} onFileChange={handleLogoFile}
/> />
</SettingsCard> </SettingsCard>
<SettingsCard
eyebrow="BROWSER BRAND"
title="Favicon generator"
description="Один квадратный исходник превращается в ICO, Apple Touch и PWA-набор; общая Save сохраняет набор на сервере и применяет его к приложению."
>
<div className="catalog-favicon-generator">
<label className="nodedc-media-file__button catalog-favicon-generator__upload">
Выбрать и сгенерировать
<input type="file" accept="image/png,image/jpeg,image/webp,.png,.jpg,.jpeg,.webp" onChange={(event) => { void handleFaviconFile(event.target.files?.[0]); }} />
</label>
<span className="catalog-favicon-generator__name">{faviconFileName}</span>
<div className="catalog-favicon-grid">
{[16, 32, 64, 180, 192, 512].map((size) => {
const generated = generatedFavicons.find((item) => item.size === size);
const fallback = size === 180 ? faviconAssets.apple : size === 192 ? faviconAssets.icon192 : size === 512 ? faviconAssets.icon512 : faviconAssets.ico;
return <figure key={size}><span><img src={generated?.previewUrl ?? fallback} alt="" /></span><figcaption>{size} × {size}</figcaption></figure>;
})}
</div>
{faviconError ? <span className="nodedc-media-field__error" role="alert">{faviconError}</span> : null}
</div>
</SettingsCard>
</div>
);
case "glass":
return (
<div className="catalog-glass-lab">
<section className="catalog-glass-stage">
<video key={stageMediaSrc} autoPlay muted loop playsInline poster="/launcher-stage-poster.png" aria-hidden="true"><source src={stageMediaSrc} type="video/mp4" /></video>
<div className="catalog-glass-stage__shade" />
<GlassMaterialSurface className="catalog-glass-stage__sample">
<span>CANONICAL GLASS</span>
<h2>Модальное окно и Inspector</h2>
<p>Фон остаётся различимым, но текст и контролы сохраняют контраст. Этот материал не применяется к обычным панелям приложения.</p>
<div className="catalog-inline"><TextField label="Пример поля" defaultValue="NODE.DC" /><Button variant="primary" shape="pill">Применить</Button></div>
</GlassMaterialSurface>
</section>
<SettingsCard eyebrow="ENGINE / GLASS V4" title="Параметры материала" description="Общий token-контракт ui-core; изменения сразу видны на preview, модалках и Inspector.">
<div className="catalog-glass-controls">
<ControlRow label="Цвет тонировки"><ColorField value={glassMaterial.tintHex} onChange={(tintHex) => setGlassMaterial((current) => ({ ...current, tintHex }))} /></ControlRow>
<RangeControl label="Прозрачность" value={glassMaterial.tintOpacity} min={0} max={96} formatValue={(value) => `${value}%`} onChange={(tintOpacity) => setGlassMaterial((current) => ({ ...current, tintOpacity }))} />
<RangeControl label="Blur" value={glassMaterial.blur} min={0} max={120} formatValue={(value) => `${value}px`} onChange={(blur) => setGlassMaterial((current) => ({ ...current, blur }))} />
<RangeControl label="Saturation" value={glassMaterial.saturation} min={70} max={220} formatValue={(value) => `${value}%`} onChange={(saturation) => setGlassMaterial((current) => ({ ...current, saturation }))} />
<RangeControl label="Brightness" value={glassMaterial.brightness} min={70} max={160} formatValue={(value) => `${value}%`} onChange={(brightness) => setGlassMaterial((current) => ({ ...current, brightness }))} />
<RangeControl label="Контур" value={glassMaterial.outlineOpacity} min={0} max={40} formatValue={(value) => `${value}%`} onChange={(outlineOpacity) => setGlassMaterial((current) => ({ ...current, outlineOpacity }))} />
<RangeControl label="Тень" value={glassMaterial.shadowOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(shadowOpacity) => setGlassMaterial((current) => ({ ...current, shadowOpacity }))} />
</div>
</SettingsCard>
</div> </div>
); );
case "modals": case "modals":
@ -933,7 +1060,7 @@ export function CatalogApp() {
footer={ footer={
<> <>
<span className="nodedc-admin-panel__nav-icon" aria-hidden="true"><Icon name="shield" /></span> <span className="nodedc-admin-panel__nav-icon" aria-hidden="true"><Icon name="shield" /></span>
<span>Design System 0.5.1</span> <span>Design System 0.6.0</span>
</> </>
} }
/> />

View File

@ -0,0 +1,84 @@
export const faviconSizes = [16, 32, 64, 180, 192, 512] as const;
export type GeneratedFavicon = {
size: (typeof faviconSizes)[number];
blob: Blob;
previewUrl: string;
};
function canvasBlob(canvas: HTMLCanvasElement) {
return new Promise<Blob>((resolve, reject) => canvas.toBlob((blob) => blob ? resolve(blob) : reject(new Error("favicon_canvas_failed")), "image/png"));
}
export async function generateFavicons(file: File): Promise<GeneratedFavicon[]> {
const bitmap = await createImageBitmap(file);
try {
return await Promise.all(faviconSizes.map(async (size) => {
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const context = canvas.getContext("2d");
if (!context) throw new Error("favicon_context_failed");
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = "high";
const scale = Math.min(size / bitmap.width, size / bitmap.height);
const width = bitmap.width * scale;
const height = bitmap.height * scale;
context.drawImage(bitmap, (size - width) / 2, (size - height) / 2, width, height);
const blob = await canvasBlob(canvas);
return { size, blob, previewUrl: URL.createObjectURL(blob) };
}));
} finally {
bitmap.close();
}
}
export async function createIcoBlob(images: GeneratedFavicon[]) {
const selected = images.filter((item) => item.size === 16 || item.size === 32 || item.size === 64);
const payloads = await Promise.all(selected.map((item) => item.blob.arrayBuffer()));
const headerSize = 6 + selected.length * 16;
const totalSize = headerSize + payloads.reduce((sum, payload) => sum + payload.byteLength, 0);
const buffer = new ArrayBuffer(totalSize);
const view = new DataView(buffer);
view.setUint16(0, 0, true);
view.setUint16(2, 1, true);
view.setUint16(4, selected.length, true);
let offset = headerSize;
selected.forEach((item, index) => {
const entry = 6 + index * 16;
const payload = payloads[index];
view.setUint8(entry, item.size);
view.setUint8(entry + 1, item.size);
view.setUint8(entry + 2, 0);
view.setUint8(entry + 3, 0);
view.setUint16(entry + 4, 1, true);
view.setUint16(entry + 6, 32, true);
view.setUint32(entry + 8, payload.byteLength, true);
view.setUint32(entry + 12, offset, true);
new Uint8Array(buffer, offset, payload.byteLength).set(new Uint8Array(payload));
offset += payload.byteLength;
});
return new Blob([buffer], { type: "image/x-icon" });
}
export type FaviconAssetUrls = { ico?: string; apple?: string; icon192?: string; icon512?: string };
export function applyFaviconAssets(assets?: FaviconAssetUrls) {
if (!assets) return;
const setLink = (id: string, rel: string, href?: string, sizes?: string) => {
if (!href) return;
let link = document.querySelector<HTMLLinkElement>(`link[data-nodedc-favicon="${id}"]`);
if (!link) {
link = document.createElement("link");
link.dataset.nodedcFavicon = id;
link.rel = rel;
document.head.append(link);
}
link.href = href;
if (sizes) link.sizes = sizes;
};
setLink("ico", "icon", assets.ico);
setLink("apple", "apple-touch-icon", assets.apple, "180x180");
setLink("192", "icon", assets.icon192, "192x192");
setLink("512", "icon", assets.icon512, "512x512");
}

View File

@ -55,6 +55,133 @@ textarea {
opacity: 0.92; opacity: 0.92;
} }
.catalog-favicon-generator {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 0.9rem 1rem;
}
.catalog-favicon-generator__upload {
position: relative;
overflow: hidden;
cursor: pointer;
}
.catalog-favicon-generator__upload input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
.catalog-favicon-generator__name {
overflow: hidden;
color: var(--nodedc-text-secondary);
text-overflow: ellipsis;
white-space: nowrap;
}
.catalog-favicon-grid {
display: grid;
grid-column: 1 / -1;
grid-template-columns: repeat(6, minmax(5rem, 1fr));
gap: 0.75rem;
}
.catalog-favicon-grid figure {
display: grid;
min-width: 0;
place-items: center;
gap: 0.5rem;
margin: 0;
padding: 0.75rem;
border-radius: var(--nodedc-radius-control);
background: var(--nodedc-canvas-soft);
}
.catalog-favicon-grid figure > span {
display: grid;
width: 3.25rem;
height: 3.25rem;
place-items: center;
border-radius: 0.9rem;
background: var(--nodedc-field-bg);
}
.catalog-favicon-grid img {
width: min(2.5rem, 100%);
height: min(2.5rem, 100%);
object-fit: contain;
}
.catalog-favicon-grid figcaption {
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
}
.catalog-glass-lab {
display: grid;
grid-template-columns: minmax(0, 1.55fr) minmax(19rem, 0.75fr);
gap: 1rem;
}
.catalog-glass-stage {
position: relative;
isolation: isolate;
display: grid;
min-height: 38rem;
place-items: center;
overflow: hidden;
border-radius: var(--nodedc-radius-panel);
background: #09090b;
}
.catalog-glass-stage > video,
.catalog-glass-stage__shade {
position: absolute;
z-index: -2;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.catalog-glass-stage__shade {
z-index: -1;
background: linear-gradient(135deg, rgba(0, 0, 0, 0.16), rgba(0, 0, 0, 0.44));
}
.catalog-glass-stage__sample {
display: grid;
width: min(34rem, calc(100% - 3rem));
gap: 0.8rem;
padding: 1.35rem;
}
.catalog-glass-stage__sample > span {
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
font-weight: var(--nodedc-font-weight-strong);
letter-spacing: 0.1em;
}
.catalog-glass-stage__sample h2,
.catalog-glass-stage__sample p { margin: 0; }
.catalog-glass-stage__sample p { color: var(--nodedc-text-secondary); line-height: 1.55; }
.catalog-glass-stage__sample .catalog-inline { align-items: end; }
.catalog-glass-stage__sample .nodedc-field { flex: 1; }
.catalog-glass-controls {
display: grid;
gap: 0.7rem;
}
@media (max-width: 1100px) {
.catalog-glass-lab { grid-template-columns: 1fr; }
.catalog-favicon-grid { grid-template-columns: repeat(3, 1fr); }
}
.catalog-launcher-stage__shade { .catalog-launcher-stage__shade {
z-index: 1; z-index: 1;
background: background:

View File

@ -37,7 +37,7 @@ Core не зависит от React.
Каталог является исполняемой документацией. Компонент не считается зафиксированным, если его состояния нельзя увидеть и проверить в каталоге. Каталог является исполняемой документацией. Компонент не считается зафиксированным, если его состояния нельзя увидеть и проверить в каталоге.
Кнопка Save в `ApplicationPanel` записывает один server-side catalog layout: тему, обе material-схемы, Environment controls, Toolbar, stage-media и знак приложения. Browser localStorage не является источником истины. Кнопка Save в `ApplicationPanel` записывает один server-side catalog layout: тему, обе material-схемы, Glass V4, Environment controls, Toolbar, stage-media, знак приложения и сгенерированный favicon-набор. Browser localStorage не является источником истины.
## Граница shared/domain ## Граница shared/domain

View File

@ -12,6 +12,8 @@
Material rim допустим только как часть floating glass. Жёсткая цветная рамка, случайный browser outline или debug border не являются rim. Material rim допустим только как часть floating glass. Жёсткая цветная рамка, случайный browser outline или debug border не являются rim.
`GlassMaterialSurface` — отдельный переносимый контракт Engine Glass V4. Он централизует tint/opacity, blur, saturation, brightness, gradient rim и shadow. Его используют только modal `Window` и modeless draggable Inspector; обычные Launcher/SEO панели остаются непрозрачными. Настройки применяются через `applyGlassMaterial`, поэтому consumer не пересобирает CSS материала вручную.
## Button и IconButton ## Button и IconButton
Button используется для всех текстовых действий. IconButton — для действия без текста. Button используется для всех текстовых действий. IconButton — для действия без текста.

24
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "nodedc-design-guideline", "name": "nodedc-design-guideline",
"version": "0.5.1", "version": "0.6.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nodedc-design-guideline", "name": "nodedc-design-guideline",
"version": "0.5.1", "version": "0.6.0",
"workspaces": [ "workspaces": [
"packages/*", "packages/*",
"apps/*" "apps/*"
@ -25,10 +25,10 @@
}, },
"apps/catalog": { "apps/catalog": {
"name": "@nodedc/ui-catalog", "name": "@nodedc/ui-catalog",
"version": "0.5.1", "version": "0.6.0",
"dependencies": { "dependencies": {
"@nodedc/ui-core": "0.5.1", "@nodedc/ui-core": "0.6.0",
"@nodedc/ui-react": "0.5.1", "@nodedc/ui-react": "0.6.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0" "react-dom": "^19.1.0"
}, },
@ -1888,27 +1888,27 @@
}, },
"packages/tokens": { "packages/tokens": {
"name": "@nodedc/tokens", "name": "@nodedc/tokens",
"version": "0.5.1" "version": "0.6.0"
}, },
"packages/ui-core": { "packages/ui-core": {
"name": "@nodedc/ui-core", "name": "@nodedc/ui-core",
"version": "0.5.1", "version": "0.6.0",
"dependencies": { "dependencies": {
"@nodedc/tokens": "0.5.1" "@nodedc/tokens": "0.6.0"
} }
}, },
"packages/ui-dom": { "packages/ui-dom": {
"name": "@nodedc/ui-dom", "name": "@nodedc/ui-dom",
"version": "0.5.1", "version": "0.6.0",
"dependencies": { "dependencies": {
"@nodedc/ui-core": "0.5.1" "@nodedc/ui-core": "0.6.0"
} }
}, },
"packages/ui-react": { "packages/ui-react": {
"name": "@nodedc/ui-react", "name": "@nodedc/ui-react",
"version": "0.5.1", "version": "0.6.0",
"dependencies": { "dependencies": {
"@nodedc/ui-core": "0.5.1", "@nodedc/ui-core": "0.6.0",
"lucide-react": "^0.468.0" "lucide-react": "^0.468.0"
}, },
"devDependencies": { "devDependencies": {

View File

@ -1,6 +1,6 @@
{ {
"name": "nodedc-design-guideline", "name": "nodedc-design-guideline",
"version": "0.5.1", "version": "0.6.0",
"private": true, "private": true,
"description": "Canonical NODE.DC design system, reusable UI packages and living component catalog.", "description": "Canonical NODE.DC design system, reusable UI packages and living component catalog.",
"type": "module", "type": "module",

View File

@ -1,8 +1,12 @@
{ {
"name": "@nodedc/tokens", "name": "@nodedc/tokens",
"version": "0.5.1", "version": "0.6.0",
"type": "module", "type": "module",
"files": ["tokens.json", "tokens.css", "themes.css"], "files": [
"tokens.json",
"tokens.css",
"themes.css"
],
"exports": { "exports": {
"./tokens.json": "./tokens.json", "./tokens.json": "./tokens.json",
"./tokens.css": "./tokens.css", "./tokens.css": "./tokens.css",

View File

@ -57,8 +57,10 @@
--nodedc-primary-action-hover: color-mix(in srgb, rgb(var(--nodedc-accent-rgb)) 84%, white); --nodedc-primary-action-hover: color-mix(in srgb, rgb(var(--nodedc-accent-rgb)) 84%, white);
--nodedc-primary-action-color: rgb(var(--nodedc-on-accent-rgb)); --nodedc-primary-action-color: rgb(var(--nodedc-on-accent-rgb));
--nodedc-primary-action-shadow: 0 12px 28px rgb(var(--nodedc-accent-rgb) / 0.2); --nodedc-primary-action-shadow: 0 12px 28px rgb(var(--nodedc-accent-rgb) / 0.2);
--nodedc-modal-surface: linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.014)), rgba(7, 7, 10, 0.54); --nodedc-modal-glass-tint-rgb: 11 11 14;
--nodedc-modal-shadow: 0 34px 120px rgba(0, 0, 0, 0.64), 0 10px 32px rgba(0, 0, 0, 0.32), inset 0 1px 0 rgba(255, 255, 255, 0.045); --nodedc-modal-glass-tint-opacity: 0.54;
--nodedc-modal-surface: linear-gradient(180deg, rgba(255, 255, 255, 0.068), rgba(255, 255, 255, 0.02)), rgb(var(--nodedc-modal-glass-tint-rgb) / var(--nodedc-modal-glass-tint-opacity));
--nodedc-modal-shadow: 0 34px 120px rgb(0 0 0 / var(--nodedc-modal-glass-shadow-opacity)), 0 10px 32px rgba(0, 0, 0, 0.32), inset 0 1px 0 rgba(255, 255, 255, 0.045);
--nodedc-floating-surface: linear-gradient(180deg, rgba(255, 255, 255, 0.045), rgba(255, 255, 255, 0.012)), rgba(10, 10, 13, 0.94); --nodedc-floating-surface: linear-gradient(180deg, rgba(255, 255, 255, 0.045), rgba(255, 255, 255, 0.012)), rgba(10, 10, 13, 0.94);
} }
@ -120,7 +122,9 @@
--nodedc-primary-action-hover: rgba(8, 8, 10, 0.98); --nodedc-primary-action-hover: rgba(8, 8, 10, 0.98);
--nodedc-primary-action-color: rgba(247, 248, 244, 0.96); --nodedc-primary-action-color: rgba(247, 248, 244, 0.96);
--nodedc-primary-action-shadow: none; --nodedc-primary-action-shadow: none;
--nodedc-modal-surface: linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.36)), rgba(255, 255, 255, 0.72); --nodedc-modal-glass-tint-rgb: 255 255 255;
--nodedc-modal-shadow: 0 24px 72px rgba(0, 0, 0, 0.24), inset 0 1px 0 rgba(255, 255, 255, 0.8); --nodedc-modal-glass-tint-opacity: 0.72;
--nodedc-modal-surface: linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.36)), rgb(var(--nodedc-modal-glass-tint-rgb) / var(--nodedc-modal-glass-tint-opacity));
--nodedc-modal-shadow: 0 24px 72px rgb(0 0 0 / var(--nodedc-modal-glass-shadow-light-opacity)), inset 0 1px 0 rgba(255, 255, 255, 0.8);
--nodedc-floating-surface: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(255, 255, 255, 0.72)), rgba(255, 255, 255, 0.94); --nodedc-floating-surface: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(255, 255, 255, 0.72)), rgba(255, 255, 255, 0.94);
} }

View File

@ -16,7 +16,7 @@
--nodedc-header-control-height: 2.78rem; --nodedc-header-control-height: 2.78rem;
--nodedc-header-logo-width: 7.25rem; --nodedc-header-logo-width: 7.25rem;
--nodedc-header-logo-height: 1.79rem; --nodedc-header-logo-height: 1.79rem;
--nodedc-inspector-width: 24.375rem; --nodedc-inspector-width: 22.125rem;
--nodedc-inspector-inner-width: 20.625rem; --nodedc-inspector-inner-width: 20.625rem;
--nodedc-inspector-label-width: 9.625rem; --nodedc-inspector-label-width: 9.625rem;
--nodedc-inspector-control-width: 10.125rem; --nodedc-inspector-control-width: 10.125rem;
@ -39,6 +39,12 @@
--nodedc-blur-control: 24px; --nodedc-blur-control: 24px;
--nodedc-blur-panel: 44px; --nodedc-blur-panel: 44px;
--nodedc-blur-modal: 64px; --nodedc-blur-modal: 64px;
--nodedc-modal-glass-saturation: 1.38;
--nodedc-modal-glass-brightness: 1.06;
--nodedc-modal-glass-outline-opacity: 0.11;
--nodedc-modal-glass-outline-soft-opacity: 0.0495;
--nodedc-modal-glass-shadow-opacity: 0.64;
--nodedc-modal-glass-shadow-light-opacity: 0.24;
--nodedc-blur-dropdown: 44px; --nodedc-blur-dropdown: 44px;
--nodedc-duration-fast: 120ms; --nodedc-duration-fast: 120ms;

View File

@ -1,8 +1,11 @@
{ {
"name": "@nodedc/ui-core", "name": "@nodedc/ui-core",
"version": "0.5.1", "version": "0.6.0",
"type": "module", "type": "module",
"files": ["dist", "styles.css"], "files": [
"dist",
"styles.css"
],
"main": "./dist/index.js", "main": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"exports": { "exports": {
@ -17,6 +20,6 @@
"typecheck": "tsc -p tsconfig.json --noEmit" "typecheck": "tsc -p tsconfig.json --noEmit"
}, },
"dependencies": { "dependencies": {
"@nodedc/tokens": "0.5.1" "@nodedc/tokens": "0.6.0"
} }
} }

View File

@ -0,0 +1,52 @@
export interface GlassMaterialSettings {
tintHex: string;
tintOpacity: number;
blur: number;
saturation: number;
brightness: number;
outlineOpacity: number;
shadowOpacity: number;
}
export const defaultGlassMaterial: GlassMaterialSettings = {
tintHex: "#0b0b0e",
tintOpacity: 54,
blur: 64,
saturation: 138,
brightness: 106,
outlineOpacity: 11,
shadowOpacity: 64,
};
function normalizeHex(value: string) {
const match = /^#?([0-9a-f]{6})$/i.exec(value.trim());
return match ? `#${match[1].toLowerCase()}` : defaultGlassMaterial.tintHex;
}
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
}
export function createGlassMaterialVariables(settings: GlassMaterialSettings): Record<string, string> {
const tintHex = normalizeHex(settings.tintHex);
const red = Number.parseInt(tintHex.slice(1, 3), 16);
const green = Number.parseInt(tintHex.slice(3, 5), 16);
const blue = Number.parseInt(tintHex.slice(5, 7), 16);
return {
"--nodedc-modal-glass-tint-rgb": `${red} ${green} ${blue}`,
"--nodedc-modal-glass-tint-opacity": String(clamp(settings.tintOpacity, 0, 100) / 100),
"--nodedc-blur-modal": `${clamp(settings.blur, 0, 120)}px`,
"--nodedc-modal-glass-saturation": String(clamp(settings.saturation, 70, 220) / 100),
"--nodedc-modal-glass-brightness": String(clamp(settings.brightness, 70, 160) / 100),
"--nodedc-modal-glass-outline-opacity": String(clamp(settings.outlineOpacity, 0, 40) / 100),
"--nodedc-modal-glass-outline-soft-opacity": String((clamp(settings.outlineOpacity, 0, 40) / 100) * 0.45),
"--nodedc-modal-glass-shadow-opacity": String(clamp(settings.shadowOpacity, 0, 100) / 100),
"--nodedc-modal-glass-shadow-light-opacity": String((clamp(settings.shadowOpacity, 0, 100) / 100) * 0.375),
};
}
export function applyGlassMaterial(target: HTMLElement, settings: GlassMaterialSettings) {
for (const [name, value] of Object.entries(createGlassMaterialVariables(settings))) {
target.style.setProperty(name, value);
}
}

View File

@ -1,4 +1,5 @@
export * from "./accent.js"; export * from "./accent.js";
export * from "./floating.js"; export * from "./floating.js";
export * from "./glass.js";
export * from "./theme.js"; export * from "./theme.js";
export * from "./toolbar.js"; export * from "./toolbar.js";

View File

@ -1914,8 +1914,8 @@ textarea.nodedc-field__control {
background: var(--nodedc-modal-surface); background: var(--nodedc-modal-surface);
color: var(--nodedc-text-primary); color: var(--nodedc-text-primary);
box-shadow: var(--nodedc-modal-shadow); box-shadow: var(--nodedc-modal-shadow);
backdrop-filter: blur(var(--nodedc-blur-modal)) saturate(1.38) brightness(1.06); backdrop-filter: blur(var(--nodedc-blur-modal)) saturate(var(--nodedc-modal-glass-saturation)) brightness(var(--nodedc-modal-glass-brightness));
-webkit-backdrop-filter: blur(var(--nodedc-blur-modal)) saturate(1.38) brightness(1.06); -webkit-backdrop-filter: blur(var(--nodedc-blur-modal)) saturate(var(--nodedc-modal-glass-saturation)) brightness(var(--nodedc-modal-glass-brightness));
animation: nodedc-window-in var(--nodedc-duration-normal) var(--nodedc-ease-standard); animation: nodedc-window-in var(--nodedc-duration-normal) var(--nodedc-ease-standard);
} }
@ -1931,13 +1931,40 @@ textarea.nodedc-field__control {
width: min(var(--nodedc-inspector-width), calc(100vw - 3rem)); width: min(var(--nodedc-inspector-width), calc(100vw - 3rem));
max-height: calc(100vh - 7.25rem); max-height: calc(100vh - 7.25rem);
border-radius: var(--nodedc-radius-panel); border-radius: var(--nodedc-radius-panel);
background: var(--nodedc-app-panel-surface); background: var(--nodedc-modal-surface);
box-shadow: var(--nodedc-app-panel-shadow); box-shadow: var(--nodedc-modal-shadow);
backdrop-filter: none; backdrop-filter: blur(var(--nodedc-blur-modal)) saturate(var(--nodedc-modal-glass-saturation)) brightness(var(--nodedc-modal-glass-brightness));
-webkit-backdrop-filter: none; -webkit-backdrop-filter: blur(var(--nodedc-blur-modal)) saturate(var(--nodedc-modal-glass-saturation)) brightness(var(--nodedc-modal-glass-brightness));
pointer-events: auto; pointer-events: auto;
} }
.nodedc-window::before,
.nodedc-glass-material::before {
position: absolute;
z-index: -1;
inset: 0;
border-radius: inherit;
padding: 1px;
background: linear-gradient(140deg, rgb(255 255 255 / var(--nodedc-modal-glass-outline-opacity)), transparent 42%, rgb(255 255 255 / var(--nodedc-modal-glass-outline-soft-opacity)));
content: "";
pointer-events: none;
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
}
.nodedc-glass-material {
position: relative;
isolation: isolate;
overflow: hidden;
border-radius: var(--nodedc-radius-modal);
background: var(--nodedc-modal-surface);
color: var(--nodedc-text-primary);
box-shadow: var(--nodedc-modal-shadow);
backdrop-filter: blur(var(--nodedc-blur-modal)) saturate(var(--nodedc-modal-glass-saturation)) brightness(var(--nodedc-modal-glass-brightness));
-webkit-backdrop-filter: blur(var(--nodedc-blur-modal)) saturate(var(--nodedc-modal-glass-saturation)) brightness(var(--nodedc-modal-glass-brightness));
}
.nodedc-window[data-placement="end"] .nodedc-window__head { .nodedc-window[data-placement="end"] .nodedc-window__head {
min-height: 4.875rem; min-height: 4.875rem;
padding: 1.125rem 0.75rem 0.875rem 1.625rem; padding: 1.125rem 0.75rem 0.875rem 1.625rem;

View File

@ -1,8 +1,10 @@
{ {
"name": "@nodedc/ui-dom", "name": "@nodedc/ui-dom",
"version": "0.5.1", "version": "0.6.0",
"type": "module", "type": "module",
"files": ["dist"], "files": [
"dist"
],
"main": "./dist/index.js", "main": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"exports": { "exports": {
@ -16,6 +18,6 @@
"typecheck": "tsc -p tsconfig.json --noEmit" "typecheck": "tsc -p tsconfig.json --noEmit"
}, },
"dependencies": { "dependencies": {
"@nodedc/ui-core": "0.5.1" "@nodedc/ui-core": "0.6.0"
} }
} }

View File

@ -1,8 +1,10 @@
{ {
"name": "@nodedc/ui-react", "name": "@nodedc/ui-react",
"version": "0.5.1", "version": "0.6.0",
"type": "module", "type": "module",
"files": ["dist"], "files": [
"dist"
],
"main": "./dist/index.js", "main": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"exports": { "exports": {
@ -16,7 +18,7 @@
"typecheck": "tsc -p tsconfig.json --noEmit" "typecheck": "tsc -p tsconfig.json --noEmit"
}, },
"dependencies": { "dependencies": {
"@nodedc/ui-core": "0.5.1", "@nodedc/ui-core": "0.6.0",
"lucide-react": "^0.468.0" "lucide-react": "^0.468.0"
}, },
"peerDependencies": { "peerDependencies": {

View File

@ -35,3 +35,11 @@ export function GlassSurface({
); );
} }
export interface GlassMaterialSurfaceProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
}
/** Canonical modal/Inspector material. General application panels must not use it. */
export function GlassMaterialSurface({ children, className, ...props }: GlassMaterialSurfaceProps) {
return <div className={cn("nodedc-glass-material", className)} {...props}>{children}</div>;
}

View File

@ -5,15 +5,16 @@
"id": "glass-surface", "id": "glass-surface",
"status": "baseline", "status": "baseline",
"package": "@nodedc/ui-react", "package": "@nodedc/ui-react",
"exports": ["GlassSurface"], "exports": ["GlassSurface", "GlassMaterialSurface"],
"domContract": ["nodedc-glass", "nodedc-material-rim"], "domContract": ["nodedc-glass", "nodedc-glass-material", "nodedc-material-rim"],
"summary": "Canonical matte/glossy glass surface for cards, panels and window shells.", "summary": "Matte application surfaces plus the canonical Engine V4 glass material for modal and Inspector shells.",
"anatomy": ["theme-provided surface", "optional material rim", "backdrop blur", "content"], "anatomy": ["theme-provided surface", "modal tint", "gradient rim", "backdrop blur/saturation/brightness", "content"],
"variants": ["default", "strong", "soft"], "variants": ["default", "strong", "soft"],
"rules": [ "rules": [
"Geometry is invariant across application themes.", "Geometry is invariant across application themes.",
"A material rim is a glass highlight, not a hard product-colored border.", "A material rim is a glass highlight, not a hard product-colored border.",
"Nested surfaces must use a softer tone to avoid card-inside-card noise." "Nested surfaces must use a softer tone to avoid card-inside-card noise.",
"GlassyMaterialSurface is restricted to modal Windows and the draggable Inspector."
] ]
}, },
{ {

View File

@ -1,6 +1,6 @@
{ {
"schemaVersion": "1.0.0", "schemaVersion": "1.0.0",
"libraryVersion": "0.5.1", "libraryVersion": "0.6.0",
"name": "NODE.DC Design System", "name": "NODE.DC Design System",
"purpose": "Canonical code-level UI system for current and future NODE.DC applications.", "purpose": "Canonical code-level UI system for current and future NODE.DC applications.",
"packages": [ "packages": [

View File

@ -18,6 +18,7 @@ const mimeTypes = {
".css": "text/css; charset=utf-8", ".css": "text/css; charset=utf-8",
".gif": "image/gif", ".gif": "image/gif",
".html": "text/html; charset=utf-8", ".html": "text/html; charset=utf-8",
".ico": "image/x-icon",
".jpg": "image/jpeg", ".jpg": "image/jpeg",
".jpeg": "image/jpeg", ".jpeg": "image/jpeg",
".js": "text/javascript; charset=utf-8", ".js": "text/javascript; charset=utf-8",
@ -48,9 +49,10 @@ async function readJsonBody(request, maxBytes = 2 * 1024 * 1024) {
} }
function safeUploadName(name, contentType = "") { function safeUploadName(name, contentType = "") {
const allowed = new Set([".gif", ".jpeg", ".jpg", ".m4v", ".mov", ".mp4", ".png", ".webm", ".webp"]); const allowed = new Set([".gif", ".ico", ".jpeg", ".jpg", ".m4v", ".mov", ".mp4", ".png", ".webm", ".webp"]);
const contentTypeExtensions = { const contentTypeExtensions = {
"image/gif": ".gif", "image/gif": ".gif",
"image/x-icon": ".ico",
"image/jpeg": ".jpg", "image/jpeg": ".jpg",
"image/png": ".png", "image/png": ".png",
"image/webp": ".webp", "image/webp": ".webp",