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

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

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 { 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>
</>
}
/>

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;
}
.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:

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

View File

@ -12,6 +12,8 @@
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 — для действия без текста.

24
package-lock.json generated
View File

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

View File

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

View File

@ -1,8 +1,12 @@
{
"name": "@nodedc/tokens",
"version": "0.5.1",
"version": "0.6.0",
"type": "module",
"files": ["tokens.json", "tokens.css", "themes.css"],
"files": [
"tokens.json",
"tokens.css",
"themes.css"
],
"exports": {
"./tokens.json": "./tokens.json",
"./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-color: rgb(var(--nodedc-on-accent-rgb));
--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-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-rgb: 11 11 14;
--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);
}
@ -120,7 +122,9 @@
--nodedc-primary-action-hover: rgba(8, 8, 10, 0.98);
--nodedc-primary-action-color: rgba(247, 248, 244, 0.96);
--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-shadow: 0 24px 72px rgba(0, 0, 0, 0.24), inset 0 1px 0 rgba(255, 255, 255, 0.8);
--nodedc-modal-glass-tint-rgb: 255 255 255;
--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);
}

View File

@ -16,7 +16,7 @@
--nodedc-header-control-height: 2.78rem;
--nodedc-header-logo-width: 7.25rem;
--nodedc-header-logo-height: 1.79rem;
--nodedc-inspector-width: 24.375rem;
--nodedc-inspector-width: 22.125rem;
--nodedc-inspector-inner-width: 20.625rem;
--nodedc-inspector-label-width: 9.625rem;
--nodedc-inspector-control-width: 10.125rem;
@ -39,6 +39,12 @@
--nodedc-blur-control: 24px;
--nodedc-blur-panel: 44px;
--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-duration-fast: 120ms;

View File

@ -1,8 +1,11 @@
{
"name": "@nodedc/ui-core",
"version": "0.5.1",
"version": "0.6.0",
"type": "module",
"files": ["dist", "styles.css"],
"files": [
"dist",
"styles.css"
],
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
@ -17,6 +20,6 @@
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"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 "./floating.js";
export * from "./glass.js";
export * from "./theme.js";
export * from "./toolbar.js";

View File

@ -1914,8 +1914,8 @@ textarea.nodedc-field__control {
background: var(--nodedc-modal-surface);
color: var(--nodedc-text-primary);
box-shadow: var(--nodedc-modal-shadow);
backdrop-filter: blur(var(--nodedc-blur-modal)) saturate(1.38) brightness(1.06);
-webkit-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(var(--nodedc-modal-glass-saturation)) brightness(var(--nodedc-modal-glass-brightness));
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));
max-height: calc(100vh - 7.25rem);
border-radius: var(--nodedc-radius-panel);
background: var(--nodedc-app-panel-surface);
box-shadow: var(--nodedc-app-panel-shadow);
backdrop-filter: none;
-webkit-backdrop-filter: none;
background: var(--nodedc-modal-surface);
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));
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 {
min-height: 4.875rem;
padding: 1.125rem 0.75rem 0.875rem 1.625rem;

View File

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

View File

@ -1,8 +1,10 @@
{
"name": "@nodedc/ui-react",
"version": "0.5.1",
"version": "0.6.0",
"type": "module",
"files": ["dist"],
"files": [
"dist"
],
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
@ -16,7 +18,7 @@
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@nodedc/ui-core": "0.5.1",
"@nodedc/ui-core": "0.6.0",
"lucide-react": "^0.468.0"
},
"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",
"status": "baseline",
"package": "@nodedc/ui-react",
"exports": ["GlassSurface"],
"domContract": ["nodedc-glass", "nodedc-material-rim"],
"summary": "Canonical matte/glossy glass surface for cards, panels and window shells.",
"anatomy": ["theme-provided surface", "optional material rim", "backdrop blur", "content"],
"exports": ["GlassSurface", "GlassMaterialSurface"],
"domContract": ["nodedc-glass", "nodedc-glass-material", "nodedc-material-rim"],
"summary": "Matte application surfaces plus the canonical Engine V4 glass material for modal and Inspector shells.",
"anatomy": ["theme-provided surface", "modal tint", "gradient rim", "backdrop blur/saturation/brightness", "content"],
"variants": ["default", "strong", "soft"],
"rules": [
"Geometry is invariant across application themes.",
"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",
"libraryVersion": "0.5.1",
"libraryVersion": "0.6.0",
"name": "NODE.DC Design System",
"purpose": "Canonical code-level UI system for current and future NODE.DC applications.",
"packages": [

View File

@ -18,6 +18,7 @@ const mimeTypes = {
".css": "text/css; charset=utf-8",
".gif": "image/gif",
".html": "text/html; charset=utf-8",
".ico": "image/x-icon",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".js": "text/javascript; charset=utf-8",
@ -48,9 +49,10 @@ async function readJsonBody(request, maxBytes = 2 * 1024 * 1024) {
}
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 = {
"image/gif": ".gif",
"image/x-icon": ".ico",
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",