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
+4 -1
View File
@@ -4,6 +4,10 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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>
</head>
<body>
@@ -11,4 +15,3 @@
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@nodedc/ui-catalog",
"version": "0.5.1",
"version": "0.6.0",
"private": true,
"type": "module",
"scripts": {
@@ -9,8 +9,8 @@
"typecheck": "tsc -b --pretty false"
},
"dependencies": {
"@nodedc/ui-core": "0.5.1",
"@nodedc/ui-react": "0.5.1",
"@nodedc/ui-core": "0.6.0",
"@nodedc/ui-react": "0.6.0",
"react": "^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

@@ -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

@@ -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"
}
]
}
+133 -6
View File
@@ -1,5 +1,5 @@
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 {
AdminNavigationPanel,
AppHeader,
@@ -13,6 +13,7 @@ import {
Dropdown,
FieldFrame,
GlassSurface,
GlassMaterialSurface,
HeaderAvatar,
HeaderNavigation,
HeaderProfile,
@@ -39,8 +40,9 @@ import {
type ShareAccessMember,
type ToolbarPlacement,
} 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 }> = [
{ label: "NODE.DC", value: [255, 47, 146], hex: "#ff2f92" },
@@ -83,7 +85,10 @@ interface StoredLayout {
logoUrl?: string;
logoFileName?: string;
logoFileSrc?: string;
faviconFileName?: string;
faviconAssets?: FaviconAssetUrls;
};
glass?: GlassMaterialSettings;
toolbar?: {
placement?: ToolbarPlacement;
background?: string;
@@ -140,14 +145,20 @@ const sectionDefinitions: Record<CatalogSection, { eyebrow: string; title: strin
description: "Общий CMS/Launcher-контракт файла, URL, превью и нейтральных карточек настроек.",
icon: "image",
},
glass: {
eyebrow: "03 / GLASSY MATERIAL",
title: "Glassy material",
description: "Единый материал только для модальных окон и перемещаемого Inspector.",
icon: "apps",
},
modals: {
eyebrow: "03 / MODALS",
eyebrow: "04 / MODALS",
title: "Модалки",
description: "Полная карта modal-паттернов Launcher, нового Engine и BIM Viewer.",
icon: "clipboard",
},
icons: {
eyebrow: "04 / ICONS",
eyebrow: "05 / ICONS",
title: "Иконки",
description: "Канонический общий набор по Launcher, SEO, BIM Viewer и новым участкам Engine.",
icon: "grid",
@@ -306,6 +317,17 @@ export function CatalogApp() {
const [logoError, setLogoError] = useState("");
const logoObjectUrlRef = useRef<string | 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 [toolbarBg, setToolbarBg] = useState("#111115");
const [toolbarBorder, setToolbarBorder] = useState("#111117");
@@ -350,6 +372,14 @@ export function CatalogApp() {
document.documentElement.style.setProperty("--nodedc-canvas-soft", material.nestedHex);
}, [accent, fieldMaterial, material.fieldOpacity, material.nestedHex, material.panelOpacity, panelMaterial, theme]);
useEffect(() => {
applyGlassMaterial(document.documentElement, glassMaterial);
}, [glassMaterial]);
useEffect(() => {
applyFaviconAssets(faviconAssets);
}, [faviconAssets]);
useEffect(() => {
let active = true;
fetch("/api/layout", { cache: "no-store" })
@@ -386,7 +416,10 @@ export function CatalogApp() {
if (typeof media.logoUrl === "string") setLogoUrl(media.logoUrl);
if (media.logoFileName) setLogoFileName(media.logoFileName);
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;
if (toolbar) {
if (toolbar.placement === "left" || toolbar.placement === "right" || toolbar.placement === "bottom") setToolbarPlacement(toolbar.placement);
@@ -412,7 +445,8 @@ export function CatalogApp() {
useEffect(() => () => {
if (mediaObjectUrlRef.current) URL.revokeObjectURL(mediaObjectUrlRef.current);
if (logoObjectUrlRef.current) URL.revokeObjectURL(logoObjectUrlRef.current);
}, []);
generatedFavicons.forEach((item) => URL.revokeObjectURL(item.previewUrl));
}, [generatedFavicons]);
const handleStageMediaFile = (file?: File) => {
if (!file) return;
@@ -449,6 +483,27 @@ export function CatalogApp() {
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 email = shareEmail.trim().toLowerCase();
if (!email) return;
@@ -506,6 +561,28 @@ export function CatalogApp() {
setLogoFileName(uploaded.fileName);
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 = {
theme,
accentHex,
@@ -532,7 +609,10 @@ export function CatalogApp() {
logoUrl,
logoFileName,
logoFileSrc: persistedLogoFileSrc,
faviconFileName,
faviconAssets: persistedFaviconAssets,
},
glass: glassMaterial,
toolbar: {
placement: toolbarPlacement,
background: toolbarBg,
@@ -785,6 +865,53 @@ export function CatalogApp() {
onFileChange={handleLogoFile}
/>
</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>
);
case "modals":
@@ -933,7 +1060,7 @@ export function CatalogApp() {
footer={
<>
<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>
</>
}
/>
+84
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");
}
+127
View File
@@ -55,6 +55,133 @@ textarea {
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 {
z-index: 1;
background: