diff --git a/apps/catalog/index.html b/apps/catalog/index.html index 1d72386..84d4f2f 100644 --- a/apps/catalog/index.html +++ b/apps/catalog/index.html @@ -4,6 +4,10 @@ + + + + NODE.DC UI Catalog @@ -11,4 +15,3 @@ - diff --git a/apps/catalog/package.json b/apps/catalog/package.json index c5900b8..29bf122 100644 --- a/apps/catalog/package.json +++ b/apps/catalog/package.json @@ -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" }, diff --git a/apps/catalog/public/favicon/apple-touch-icon.png b/apps/catalog/public/favicon/apple-touch-icon.png new file mode 100644 index 0000000..5078c01 Binary files /dev/null and b/apps/catalog/public/favicon/apple-touch-icon.png differ diff --git a/apps/catalog/public/favicon/favicon.ico b/apps/catalog/public/favicon/favicon.ico new file mode 100644 index 0000000..973f3b2 Binary files /dev/null and b/apps/catalog/public/favicon/favicon.ico differ diff --git a/apps/catalog/public/favicon/icon-192.png b/apps/catalog/public/favicon/icon-192.png new file mode 100644 index 0000000..d553a58 Binary files /dev/null and b/apps/catalog/public/favicon/icon-192.png differ diff --git a/apps/catalog/public/favicon/icon-512.png b/apps/catalog/public/favicon/icon-512.png new file mode 100644 index 0000000..a6f47b9 Binary files /dev/null and b/apps/catalog/public/favicon/icon-512.png differ diff --git a/apps/catalog/public/favicon/icon-adaptive.svg b/apps/catalog/public/favicon/icon-adaptive.svg new file mode 100644 index 0000000..fee636f --- /dev/null +++ b/apps/catalog/public/favicon/icon-adaptive.svg @@ -0,0 +1,13 @@ + + + + + \ No newline at end of file diff --git a/apps/catalog/public/favicon/manifest.webmanifest.json b/apps/catalog/public/favicon/manifest.webmanifest.json new file mode 100644 index 0000000..f7256e8 --- /dev/null +++ b/apps/catalog/public/favicon/manifest.webmanifest.json @@ -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" + } + ] +} diff --git a/apps/catalog/src/CatalogApp.tsx b/apps/catalog/src/CatalogApp.tsx index 3aa8ab7..9fe8138 100644 --- a/apps/catalog/src/CatalogApp.tsx +++ b/apps/catalog/src/CatalogApp.tsx @@ -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(null); const pendingLogoFileRef = useRef(null); + const [glassMaterial, setGlassMaterial] = useState({ ...defaultGlassMaterial }); + const [faviconFileName, setFaviconFileName] = useState("icon-adaptive.svg"); + const [faviconError, setFaviconError] = useState(""); + const [generatedFavicons, setGeneratedFavicons] = useState([]); + const [faviconAssets, setFaviconAssets] = useState({ + ico: "/favicon/favicon.ico", + apple: "/favicon/apple-touch-icon.png", + icon192: "/favicon/icon-192.png", + icon512: "/favicon/icon-512.png", + }); + const pendingFaviconIcoRef = useRef(null); const [toolbarPlacement, setToolbarPlacement] = useState("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} /> + +
+ + {faviconFileName} +
+ {[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
{size} × {size}
; + })} +
+ {faviconError ? {faviconError} : null} +
+
+ + ); + case "glass": + return ( +
+
+ +
+ + CANONICAL GLASS +

Модальное окно и Inspector

+

Фон остаётся различимым, но текст и контролы сохраняют контраст. Этот материал не применяется к обычным панелям приложения.

+
+
+
+ +
+ setGlassMaterial((current) => ({ ...current, tintHex }))} /> + `${value}%`} onChange={(tintOpacity) => setGlassMaterial((current) => ({ ...current, tintOpacity }))} /> + `${value}px`} onChange={(blur) => setGlassMaterial((current) => ({ ...current, blur }))} /> + `${value}%`} onChange={(saturation) => setGlassMaterial((current) => ({ ...current, saturation }))} /> + `${value}%`} onChange={(brightness) => setGlassMaterial((current) => ({ ...current, brightness }))} /> + `${value}%`} onChange={(outlineOpacity) => setGlassMaterial((current) => ({ ...current, outlineOpacity }))} /> + `${value}%`} onChange={(shadowOpacity) => setGlassMaterial((current) => ({ ...current, shadowOpacity }))} /> +
+
); case "modals": @@ -933,7 +1060,7 @@ export function CatalogApp() { footer={ <> - Design System 0.5.1 + Design System 0.6.0 } /> diff --git a/apps/catalog/src/favicon.ts b/apps/catalog/src/favicon.ts new file mode 100644 index 0000000..b2e4747 --- /dev/null +++ b/apps/catalog/src/favicon.ts @@ -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((resolve, reject) => canvas.toBlob((blob) => blob ? resolve(blob) : reject(new Error("favicon_canvas_failed")), "image/png")); +} + +export async function generateFavicons(file: File): Promise { + 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(`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"); +} diff --git a/apps/catalog/src/styles.css b/apps/catalog/src/styles.css index 975be16..5cd75cd 100644 --- a/apps/catalog/src/styles.css +++ b/apps/catalog/src/styles.css @@ -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: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e1e92c3..262add9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index 7ed9c42..493dc2d 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -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 — для действия без текста. diff --git a/package-lock.json b/package-lock.json index 694774e..3fc180c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index cade927..e89fa9b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/tokens/package.json b/packages/tokens/package.json index 3bb8a0f..fc9ded3 100644 --- a/packages/tokens/package.json +++ b/packages/tokens/package.json @@ -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", diff --git a/packages/tokens/themes.css b/packages/tokens/themes.css index 3a12c50..1c78da4 100644 --- a/packages/tokens/themes.css +++ b/packages/tokens/themes.css @@ -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); } diff --git a/packages/tokens/tokens.css b/packages/tokens/tokens.css index 1f35e9e..acc2b4a 100644 --- a/packages/tokens/tokens.css +++ b/packages/tokens/tokens.css @@ -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; diff --git a/packages/ui-core/package.json b/packages/ui-core/package.json index 0946474..6901f37 100644 --- a/packages/ui-core/package.json +++ b/packages/ui-core/package.json @@ -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" } } diff --git a/packages/ui-core/src/glass.ts b/packages/ui-core/src/glass.ts new file mode 100644 index 0000000..c4e13a0 --- /dev/null +++ b/packages/ui-core/src/glass.ts @@ -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 { + 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); + } +} diff --git a/packages/ui-core/src/index.ts b/packages/ui-core/src/index.ts index 33b6ece..3c8b7a3 100644 --- a/packages/ui-core/src/index.ts +++ b/packages/ui-core/src/index.ts @@ -1,4 +1,5 @@ export * from "./accent.js"; export * from "./floating.js"; +export * from "./glass.js"; export * from "./theme.js"; export * from "./toolbar.js"; diff --git a/packages/ui-core/styles.css b/packages/ui-core/styles.css index 4c81cbc..01f5b37 100644 --- a/packages/ui-core/styles.css +++ b/packages/ui-core/styles.css @@ -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; diff --git a/packages/ui-dom/package.json b/packages/ui-dom/package.json index 3a5c73d..fbbc6ba 100644 --- a/packages/ui-dom/package.json +++ b/packages/ui-dom/package.json @@ -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" } } diff --git a/packages/ui-react/package.json b/packages/ui-react/package.json index b188c42..22f2123 100644 --- a/packages/ui-react/package.json +++ b/packages/ui-react/package.json @@ -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": { diff --git a/packages/ui-react/src/Glass.tsx b/packages/ui-react/src/Glass.tsx index 05f5722..a304132 100644 --- a/packages/ui-react/src/Glass.tsx +++ b/packages/ui-react/src/Glass.tsx @@ -35,3 +35,11 @@ export function GlassSurface({ ); } +export interface GlassMaterialSurfaceProps extends HTMLAttributes { + children: ReactNode; +} + +/** Canonical modal/Inspector material. General application panels must not use it. */ +export function GlassMaterialSurface({ children, className, ...props }: GlassMaterialSurfaceProps) { + return
{children}
; +} diff --git a/registry/components.json b/registry/components.json index cffaf75..d4dd665 100644 --- a/registry/components.json +++ b/registry/components.json @@ -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." ] }, { diff --git a/registry/registry.json b/registry/registry.json index f33a5fe..59cab32 100644 --- a/registry/registry.json +++ b/registry/registry.json @@ -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": [ diff --git a/server/catalog-server.mjs b/server/catalog-server.mjs index e7390ec..f45e1ce 100644 --- a/server/catalog-server.mjs +++ b/server/catalog-server.mjs @@ -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",