Add versioned design profile releases

This commit is contained in:
DCCONSTRUCTIONS 2026-07-11 22:34:01 +03:00
parent 50718a33a1
commit 1f2dffc031
8 changed files with 473 additions and 17 deletions

View File

@ -56,6 +56,7 @@ import {
type ApplicationManifestV01,
type ApplicationSummary,
type DesignProfileSummary,
type DesignProfileStatus,
} from "./applicationManifest.js";
type CatalogSection = "controls" | "media" | "glass" | "modals" | "icons";
@ -315,6 +316,7 @@ export function CatalogApp() {
const [designProfileSaveOpen, setDesignProfileSaveOpen] = useState(false);
const [designProfileSaveMode, setDesignProfileSaveMode] = useState<"save" | "save-as">("save");
const [designProfileName, setDesignProfileName] = useState("");
const [designProfilePublishState, setDesignProfilePublishState] = useState<"idle" | "publishing" | "published" | "error">("idle");
const [activeDesignProfileId, setActiveDesignProfileId] = useState("default");
const workspace = useApplicationWorkspace<StudioView>();
const { navigationOpen: guidelineOpen, activeView, contentExpanded: panelExpanded } = workspace;
@ -885,8 +887,18 @@ export function CatalogApp() {
body: JSON.stringify({ name: creating ? designProfileName : selected?.name, layout }),
});
if (!response.ok) return setLayoutSaveState("error");
const saved = await response.json() as { id: string; name: string; version: string; layout: StoredLayout; timestamps: { updatedAt: string } };
const summary: DesignProfileSummary = { id: saved.id, name: saved.name, version: saved.version, theme: saved.layout.theme === "light" ? "light" : "dark", updatedAt: saved.timestamps.updatedAt };
const saved = await response.json() as { id: string; name: string; version: string; status: DesignProfileStatus; layout: StoredLayout; timestamps: { updatedAt: string } };
const previous = designProfiles.find((profile) => profile.id === saved.id);
const summary: DesignProfileSummary = {
id: saved.id,
name: saved.name,
version: saved.version,
status: saved.status,
theme: saved.layout.theme === "light" ? "light" : "dark",
updatedAt: saved.timestamps.updatedAt,
versions: previous?.versions ?? [],
latestPublishedVersion: previous?.latestPublishedVersion ?? null,
};
setDesignProfiles((current) => [...current.filter((profile) => profile.id !== summary.id), summary].sort((left, right) => left.name.localeCompare(right.name)));
setActiveDesignProfileId(summary.id);
setDesignProfileSaveOpen(false);
@ -894,6 +906,25 @@ export function CatalogApp() {
setLayoutSaveState("saved");
};
const publishDesignProfile = async () => {
const current = designProfiles.find((profile) => profile.id === activeDesignProfileId);
if (!current || current.versions.some((release) => release.version === current.version)) return;
setDesignProfilePublishState("publishing");
const response = await fetch(`/api/design-profiles/${current.id}/publish`, { method: "POST" });
if (!response.ok) {
setDesignProfilePublishState("error");
return;
}
const release = await response.json() as { version: string; status: DesignProfileStatus; layout: StoredLayout; timestamps: { publishedAt?: string } };
setDesignProfiles((profiles) => profiles.map((profile) => profile.id !== current.id ? profile : {
...profile,
versions: [{ version: release.version, status: release.status, theme: release.layout.theme === "light" ? "light" : "dark", publishedAt: release.timestamps.publishedAt }, ...profile.versions.filter((item) => item.version !== release.version)],
latestPublishedVersion: release.version,
}));
setDesignProfilePublishState("published");
setDesignProfileSaveOpen(false);
};
const addPageTemplateToApplication = (template: PageTemplateDefinition) => {
updateApplicationDraft((current) => {
const order = current.pages.length;
@ -1312,6 +1343,19 @@ export function CatalogApp() {
const renderApplicationDraft = () => {
if (!applicationDraft || applicationDraft.id !== activeApplicationId) return null;
const selectedProfile = designProfiles.find((profile) => profile.id === applicationDraft.designProfile.id) ?? designProfiles[0];
const designProfileOptions = designProfiles.flatMap((profile) => [
...profile.versions.map((release) => ({
value: `${profile.id}@${release.version}@published`,
label: profile.name,
description: `${release.version} · Published · ${release.theme}`,
})),
{
value: `${profile.id}@${profile.version}@draft`,
label: `${profile.name} — Draft`,
description: `${profile.version} · Draft · ${profile.theme}`,
},
]);
const selectedProfileValue = `${applicationDraft.designProfile.id}@${applicationDraft.designProfile.version}@${applicationDraft.designProfile.status}`;
const availableTemplates = pageTemplates;
if (applicationMode === "preview") {
@ -1373,13 +1417,17 @@ export function CatalogApp() {
>
<Select
label="Design Profile"
value={selectedProfile?.id ?? "default"}
options={designProfiles.map((profile) => ({ value: profile.id, label: profile.name, description: `${profile.version} · ${profile.theme}` }))}
onChange={(profileId) => {
value={designProfileOptions.some((option) => option.value === selectedProfileValue) ? selectedProfileValue : `${selectedProfile?.id ?? "default"}@${selectedProfile?.version ?? "0.6.0"}@draft`}
options={designProfileOptions}
onChange={(profileReference) => {
const [profileId, version, statusValue] = profileReference.split("@");
const profile = designProfiles.find((item) => item.id === profileId);
if (!profile) return;
setTheme(profile.theme);
updateApplicationDraft((current) => ({ ...current, designProfile: { id: profile.id, version: profile.version, theme: profile.theme } }));
const status: DesignProfileStatus = statusValue === "published" ? "published" : "draft";
const release = status === "published" ? profile.versions.find((item) => item.version === version) : undefined;
const profileTheme = release?.theme ?? profile.theme;
setTheme(profileTheme);
updateApplicationDraft((current) => ({ ...current, designProfile: { id: profile.id, version, status, theme: profileTheme } }));
}}
/>
</SettingsCard>
@ -1623,7 +1671,7 @@ export function CatalogApp() {
className="catalog-module-switcher"
label="Design Profile"
value={activeDesignProfileId}
options={designProfiles.map((profile) => ({ value: profile.id, label: profile.name, description: `${profile.version} · ${profile.theme}`, icon: <Icon name="settings" /> }))}
options={designProfiles.map((profile) => ({ value: profile.id, label: profile.name, description: `${profile.version} · Draft · ${profile.versions.length} releases`, icon: <Icon name="settings" /> }))}
onChange={(id) => { void openDesignProfile(id); }}
/>
) : undefined}
@ -1798,6 +1846,10 @@ export function CatalogApp() {
{designProfileSaveMode === "save" ? (
<>
<Button onClick={() => { void saveDesignProfile("save"); }}>Сохранить</Button>
<Button
disabled={designProfilePublishState === "publishing" || Boolean(designProfiles.find((profile) => profile.id === activeDesignProfileId)?.versions.some((release) => release.version === designProfiles.find((profile) => profile.id === activeDesignProfileId)?.version))}
onClick={() => { void publishDesignProfile(); }}
>{designProfilePublishState === "publishing" ? "Публикуется" : `Опубликовать v${designProfiles.find((profile) => profile.id === activeDesignProfileId)?.version ?? ""}`}</Button>
<Button variant="primary" shape="pill" onClick={() => { setDesignProfileName(""); setDesignProfileSaveMode("save-as"); }}>Сохранить как новый</Button>
</>
) : (
@ -1810,7 +1862,7 @@ export function CatalogApp() {
<div className="catalog-form">
<SegmentedControl label="Режим сохранения" value={designProfileSaveMode} items={[{ value: "save", label: "Сохранить" }, { value: "save-as", label: "Сохранить как новый" }]} onChange={setDesignProfileSaveMode} />
{designProfileSaveMode === "save-as" ? <TextField label="Название нового профиля" hint="обязательно" value={designProfileName} onChange={(event) => setDesignProfileName(event.target.value)} /> : (
<div className="catalog-save-profile-current"><Icon name="settings" /><span><strong>{designProfiles.find((profile) => profile.id === activeDesignProfileId)?.name ?? "NODE.DC Default"}</strong><small>Будет создана новая draft-версия текущего профиля.</small></span></div>
<div className="catalog-save-profile-current"><Icon name="settings" /><span><strong>{designProfiles.find((profile) => profile.id === activeDesignProfileId)?.name ?? "NODE.DC Default"}</strong><small>Сохранение создаёт новую draft-версию. Publish фиксирует текущую сохранённую версию неизменяемым release.</small></span></div>
)}
</div>
</Window>

View File

@ -3,6 +3,7 @@ import type { NodedcTheme } from "@nodedc/ui-core";
export const applicationManifestSchemaVersion = "0.1.0" as const;
export type ApplicationDraftStatus = "draft";
export type DesignProfileStatus = "draft" | "published";
export interface ApplicationPageManifest {
id: string;
@ -33,6 +34,7 @@ export interface ApplicationManifestV01 {
designProfile: {
id: string;
version: string;
status: DesignProfileStatus;
theme: NodedcTheme;
};
pages: ApplicationPageManifest[];
@ -60,8 +62,18 @@ export interface DesignProfileSummary {
id: string;
name: string;
version: string;
status: DesignProfileStatus;
theme: NodedcTheme;
updatedAt: string;
versions: DesignProfileVersionSummary[];
latestPublishedVersion: string | null;
}
export interface DesignProfileVersionSummary {
version: string;
status: DesignProfileStatus;
theme: NodedcTheme;
publishedAt?: string;
}
export type ApplicationDraftSaveState = "idle" | "loading" | "saving" | "saved" | "error";

View File

@ -23,7 +23,7 @@
Manifest фиксирует:
- identity, slug, draft status и версию;
- ссылку на Design Profile и Dark/Light;
- ссылку на конкретный Design Profile release (`id + version + status + theme`);
- хотя бы одну страницу и pinned page-template version;
- navigation visibility;
- только заранее разрешённые feature flags;
@ -58,7 +58,11 @@ Catalog server предоставляет минимальный временн
## Design Profile lifecycle
Visual Library использует отдельный Hub-style selector профилей. Общая кнопка Save открывает каноническую модалку `Сохранить / Сохранить как новый`. Приложение хранит только pinned `profile id + version + theme`; media, favicon и material settings принадлежат Design Profile.
Каноническая JSON Schema: `registry/schemas/design-profile-v0.1.schema.json`.
Visual Library использует отдельный Hub-style selector профилей. Общая кнопка Save открывает каноническую модалку `Сохранить / Сохранить как новый / Опубликовать`. Приложение хранит pinned `profile id + version + status + theme`; media, favicon и material settings принадлежат Design Profile.
`Draft` — изменяемая рабочая голова профиля. Каждое сохранение увеличивает patch-версию. `Published` — неизменяемый снимок конкретной версии: повторная публикация той же версии запрещена. Application Manifest может ссылаться на draft для внутренней разработки, но стабильный модуль должен фиксировать published release. Изменение будущего draft или публикация следующей версии не меняют уже закреплённое приложение.
Временный catalog server предоставляет:
@ -66,4 +70,9 @@ Visual Library использует отдельный Hub-style selector про
- `GET /api/design-profiles/:id`;
- `POST /api/design-profiles`;
- `PUT /api/design-profiles/:id`;
- `GET /api/design-profiles/:id/versions`;
- `GET /api/design-profiles/:id/versions/:version`;
- `POST /api/design-profiles/:id/publish`;
- `DELETE /api/applications/:id` с переносом draft в локальное deleted-storage.
Catalog server проверяет полный layout-контракт Design Profile и существование выбранной Application Manifest ссылки при создании и сохранении модуля. Опубликованные снимки лежат отдельно от draft-head в `runtime-data/design-profile-releases` и исключены из Git; в production этот lifecycle должен перейти в Platform `design-profile-core` без изменения публичного контракта.

View File

@ -50,5 +50,6 @@
"consumption": "../docs/CONSUMPTION.md"
},
"applicationManifestSchema": "schemas/application-manifest-v0.1.schema.json",
"designProfileSchema": "schemas/design-profile-v0.1.schema.json",
"operatingRule": "Check this registry before creating a NODE.DC interface element. Reuse or extend the canonical export instead of creating an application-local copy."
}

View File

@ -23,10 +23,11 @@
"designProfile": {
"type": "object",
"additionalProperties": false,
"required": ["id", "version", "theme"],
"required": ["id", "version", "status", "theme"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"version": { "type": "string", "minLength": 1 },
"status": { "enum": ["draft", "published"] },
"theme": { "enum": ["dark", "light"] }
}
},

View File

@ -0,0 +1,134 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://nodedc.ru/schemas/design-profile-v0.1.schema.json",
"title": "NODE.DC Design Profile v0.1",
"type": "object",
"additionalProperties": false,
"required": ["schemaVersion", "id", "name", "version", "status", "layout", "timestamps"],
"properties": {
"schemaVersion": { "const": "0.1.0" },
"id": { "type": "string", "anyOf": [{ "const": "default" }, { "format": "uuid" }] },
"name": { "type": "string", "minLength": 1, "maxLength": 120 },
"version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" },
"status": { "enum": ["draft", "published"] },
"layout": {
"type": "object",
"additionalProperties": false,
"required": ["theme", "accentHex", "materialByTheme", "environment", "media", "glass", "toolbar"],
"properties": {
"theme": { "enum": ["dark", "light"] },
"accentHex": { "$ref": "#/$defs/hex" },
"materialByTheme": {
"type": "object",
"additionalProperties": false,
"required": ["dark", "light"],
"properties": {
"dark": { "$ref": "#/$defs/material" },
"light": { "$ref": "#/$defs/material" }
}
},
"environment": {
"type": "object",
"additionalProperties": false,
"required": ["lightColor", "brightness", "glowDistance", "connectionType", "connectionColor", "usePortColors", "fillColor", "fillOpacity", "strokeColor", "strokeOpacity"],
"properties": {
"lightColor": { "$ref": "#/$defs/hex" },
"brightness": { "$ref": "#/$defs/percent" },
"glowDistance": { "type": "number", "minimum": 0, "maximum": 500 },
"connectionType": { "type": "string", "minLength": 1, "maxLength": 64 },
"connectionColor": { "$ref": "#/$defs/hex" },
"usePortColors": { "type": "boolean" },
"fillColor": { "$ref": "#/$defs/hex" },
"fillOpacity": { "$ref": "#/$defs/percent" },
"strokeColor": { "$ref": "#/$defs/hex" },
"strokeOpacity": { "$ref": "#/$defs/percent" }
}
},
"media": {
"type": "object",
"additionalProperties": false,
"required": ["source", "url", "fileName", "fileSrc", "visible", "logoSource", "logoUrl", "logoFileName", "logoFileSrc", "faviconFileName", "faviconAssets"],
"properties": {
"source": { "enum": ["file", "url"] },
"url": { "type": "string" },
"fileName": { "type": "string" },
"fileSrc": { "type": "string" },
"visible": { "type": "boolean" },
"logoSource": { "enum": ["file", "url"] },
"logoUrl": { "type": "string" },
"logoFileName": { "type": "string" },
"logoFileSrc": { "type": "string" },
"faviconFileName": { "type": "string" },
"faviconAssets": {
"type": "object",
"additionalProperties": false,
"required": ["ico", "apple", "icon192", "icon512"],
"properties": {
"ico": { "type": "string", "minLength": 1 },
"apple": { "type": "string", "minLength": 1 },
"icon192": { "type": "string", "minLength": 1 },
"icon512": { "type": "string", "minLength": 1 }
}
}
}
},
"glass": {
"type": "object",
"additionalProperties": false,
"required": ["version", "tintHex", "tintOpacity", "blur", "saturation", "brightness", "outlineOpacity", "shadowOpacity"],
"properties": {
"version": { "const": 4 },
"tintHex": { "$ref": "#/$defs/hex" },
"tintOpacity": { "$ref": "#/$defs/percent" },
"blur": { "type": "number", "minimum": 0, "maximum": 120 },
"saturation": { "type": "number", "minimum": 0, "maximum": 300 },
"brightness": { "type": "number", "minimum": 0, "maximum": 200 },
"outlineOpacity": { "$ref": "#/$defs/percent" },
"shadowOpacity": { "$ref": "#/$defs/percent" }
}
},
"toolbar": {
"type": "object",
"additionalProperties": false,
"required": ["placement", "background", "border", "outline", "minSize", "maxSize", "lensCount", "autoHide"],
"properties": {
"placement": { "enum": ["left", "right", "bottom"] },
"background": { "$ref": "#/$defs/hex" },
"border": { "$ref": "#/$defs/hex" },
"outline": { "$ref": "#/$defs/hex" },
"minSize": { "type": "number", "minimum": 16, "maximum": 64 },
"maxSize": { "type": "number", "minimum": 32, "maximum": 160 },
"lensCount": { "type": "integer", "minimum": 1, "maximum": 12 },
"autoHide": { "type": "boolean" }
}
}
}
},
"timestamps": {
"type": "object",
"additionalProperties": false,
"required": ["createdAt", "updatedAt"],
"properties": {
"createdAt": { "type": "string", "format": "date-time" },
"updatedAt": { "type": "string", "format": "date-time" },
"publishedAt": { "type": "string", "format": "date-time" }
}
}
},
"$defs": {
"hex": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" },
"percent": { "type": "number", "minimum": 0, "maximum": 100 },
"material": {
"type": "object",
"additionalProperties": false,
"required": ["panelHex", "panelOpacity", "fieldHex", "fieldOpacity", "nestedHex"],
"properties": {
"panelHex": { "$ref": "#/$defs/hex" },
"panelOpacity": { "$ref": "#/$defs/percent" },
"fieldHex": { "$ref": "#/$defs/hex" },
"fieldOpacity": { "$ref": "#/$defs/percent" },
"nestedHex": { "$ref": "#/$defs/hex" }
}
}
}
}

View File

@ -11,6 +11,8 @@ const components = await parse("registry/components.json");
const candidates = await parse("registry/candidates.json");
const icons = await parse("registry/icons.json");
const pages = await parse("registry/pages.json");
const applicationManifestSchema = await parse("registry/schemas/application-manifest-v0.1.schema.json");
const designProfileSchema = await parse("registry/schemas/design-profile-v0.1.schema.json");
const failures = [];
const requireValue = (condition, message) => {
@ -26,6 +28,10 @@ requireValue(Array.isArray(candidates.candidates) && candidates.candidates.lengt
requireValue(Array.isArray(icons.groups) && icons.groups.length >= 5, "icon registry is incomplete");
requireValue(pages.schemaVersion === "0.1.0", "page registry schemaVersion must be 0.1.0");
requireValue(Array.isArray(pages.templates) && pages.templates.length >= 1, "page registry must contain at least one template");
requireValue(registry.applicationManifestSchema === "schemas/application-manifest-v0.1.schema.json", "application manifest schema must be registered");
requireValue(registry.designProfileSchema === "schemas/design-profile-v0.1.schema.json", "design profile schema must be registered");
requireValue(applicationManifestSchema.properties?.schemaVersion?.const === "0.1.0", "application manifest schema version must be 0.1.0");
requireValue(designProfileSchema.properties?.schemaVersion?.const === "0.1.0", "design profile schema version must be 0.1.0");
const ids = components.components.map((component) => component.id);
requireValue(new Set(ids).size === ids.length, "component ids must be unique");

View File

@ -12,6 +12,7 @@ const runtimeDir = join(root, "runtime-data");
const uploadDir = join(runtimeDir, "uploads");
const applicationsDir = join(runtimeDir, "applications");
const designProfilesDir = join(runtimeDir, "design-profiles");
const designProfileReleasesDir = join(runtimeDir, "design-profile-releases");
const layoutPath = join(runtimeDir, "layout.json");
const pageRegistryPath = join(root, "registry", "pages.json");
const port = Number(process.env.PORT || 3333);
@ -19,6 +20,7 @@ const port = Number(process.env.PORT || 3333);
await mkdir(uploadDir, { recursive: true });
await mkdir(applicationsDir, { recursive: true });
await mkdir(designProfilesDir, { recursive: true });
await mkdir(designProfileReleasesDir, { recursive: true });
const pageRegistry = JSON.parse(await readFile(pageRegistryPath, "utf8"));
function findPageTemplate(id, version) {
@ -76,14 +78,144 @@ function designProfilePath(id) {
return join(designProfilesDir, `${safeId}.json`);
}
function designProfileReleaseDir(id) {
const safeId = String(id || "");
if (safeId !== "default" && !/^[0-9a-f-]{36}$/i.test(safeId)) throw applicationError("invalid_design_profile_id");
return join(designProfileReleasesDir, safeId);
}
function designProfileReleasePath(id, version) {
const safeVersion = String(version || "");
if (!/^\d+\.\d+\.\d+$/.test(safeVersion)) throw applicationError("invalid_design_profile_version");
return join(designProfileReleaseDir(id), `${safeVersion}.json`);
}
const isObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
const requireHex = (value, code) => {
const normalized = String(value || "");
if (!/^#[0-9a-f]{6}$/i.test(normalized)) throw applicationError(code);
return normalized.toLowerCase();
};
const requireNumber = (value, min, max, code) => {
if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) throw applicationError(code);
return value;
};
const requireInteger = (value, min, max, code) => {
const normalized = requireNumber(value, min, max, code);
if (!Number.isInteger(normalized)) throw applicationError(code);
return normalized;
};
const requireString = (value, code, max = 4096) => {
if (typeof value !== "string" || value.length > max) throw applicationError(code);
return value;
};
const requireNonEmptyString = (value, code, max = 4096) => {
const normalized = requireString(value, code, max);
if (!normalized) throw applicationError(code);
return normalized;
};
const requireBoolean = (value, code) => {
if (typeof value !== "boolean") throw applicationError(code);
return value;
};
function validateMaterial(value, theme) {
if (!isObject(value)) throw applicationError(`invalid_${theme}_material`);
return {
panelHex: requireHex(value.panelHex, `invalid_${theme}_panel_color`),
panelOpacity: requireNumber(value.panelOpacity, 0, 100, `invalid_${theme}_panel_opacity`),
fieldHex: requireHex(value.fieldHex, `invalid_${theme}_field_color`),
fieldOpacity: requireNumber(value.fieldOpacity, 0, 100, `invalid_${theme}_field_opacity`),
nestedHex: requireHex(value.nestedHex, `invalid_${theme}_nested_color`),
};
}
function validateDesignProfileLayout(value) {
if (!isObject(value)) throw applicationError("invalid_design_profile_layout");
if (value.theme !== "dark" && value.theme !== "light") throw applicationError("invalid_design_profile_theme");
if (!isObject(value.materialByTheme) || !isObject(value.environment) || !isObject(value.media) || !isObject(value.glass) || !isObject(value.toolbar)) {
throw applicationError("incomplete_design_profile_layout");
}
const environment = value.environment;
const media = value.media;
const glass = value.glass;
const toolbar = value.toolbar;
if (media.source !== "file" && media.source !== "url") throw applicationError("invalid_design_profile_media_source");
if (media.logoSource !== "file" && media.logoSource !== "url") throw applicationError("invalid_design_profile_logo_source");
if (!isObject(media.faviconAssets)) throw applicationError("invalid_design_profile_favicon_assets");
if (glass.version !== 4) throw applicationError("unsupported_glass_material_version");
if (!["left", "right", "bottom"].includes(toolbar.placement)) throw applicationError("invalid_toolbar_placement");
return {
theme: value.theme,
accentHex: requireHex(value.accentHex, "invalid_design_profile_accent"),
materialByTheme: {
dark: validateMaterial(value.materialByTheme.dark, "dark"),
light: validateMaterial(value.materialByTheme.light, "light"),
},
environment: {
lightColor: requireHex(environment.lightColor, "invalid_environment_light_color"),
brightness: requireNumber(environment.brightness, 0, 100, "invalid_environment_brightness"),
glowDistance: requireNumber(environment.glowDistance, 0, 500, "invalid_environment_glow_distance"),
connectionType: requireString(environment.connectionType, "invalid_environment_connection_type", 64),
connectionColor: requireHex(environment.connectionColor, "invalid_environment_connection_color"),
usePortColors: requireBoolean(environment.usePortColors, "invalid_environment_use_port_colors"),
fillColor: requireHex(environment.fillColor, "invalid_environment_fill_color"),
fillOpacity: requireNumber(environment.fillOpacity, 0, 100, "invalid_environment_fill_opacity"),
strokeColor: requireHex(environment.strokeColor, "invalid_environment_stroke_color"),
strokeOpacity: requireNumber(environment.strokeOpacity, 0, 100, "invalid_environment_stroke_opacity"),
},
media: {
source: media.source,
url: requireString(media.url, "invalid_design_profile_media_url"),
fileName: requireString(media.fileName, "invalid_design_profile_media_file_name", 255),
fileSrc: requireString(media.fileSrc, "invalid_design_profile_media_file_src"),
visible: requireBoolean(media.visible, "invalid_design_profile_media_visibility"),
logoSource: media.logoSource,
logoUrl: requireString(media.logoUrl, "invalid_design_profile_logo_url"),
logoFileName: requireString(media.logoFileName, "invalid_design_profile_logo_file_name", 255),
logoFileSrc: requireString(media.logoFileSrc, "invalid_design_profile_logo_file_src"),
faviconFileName: requireString(media.faviconFileName, "invalid_design_profile_favicon_file_name", 255),
faviconAssets: {
ico: requireNonEmptyString(media.faviconAssets.ico, "invalid_design_profile_favicon_ico"),
apple: requireNonEmptyString(media.faviconAssets.apple, "invalid_design_profile_favicon_apple"),
icon192: requireNonEmptyString(media.faviconAssets.icon192, "invalid_design_profile_favicon_192"),
icon512: requireNonEmptyString(media.faviconAssets.icon512, "invalid_design_profile_favicon_512"),
},
},
glass: {
version: 4,
tintHex: requireHex(glass.tintHex, "invalid_glass_tint"),
tintOpacity: requireNumber(glass.tintOpacity, 0, 100, "invalid_glass_tint_opacity"),
blur: requireNumber(glass.blur, 0, 120, "invalid_glass_blur"),
saturation: requireNumber(glass.saturation, 0, 300, "invalid_glass_saturation"),
brightness: requireNumber(glass.brightness, 0, 200, "invalid_glass_brightness"),
outlineOpacity: requireNumber(glass.outlineOpacity, 0, 100, "invalid_glass_outline_opacity"),
shadowOpacity: requireNumber(glass.shadowOpacity, 0, 100, "invalid_glass_shadow_opacity"),
},
toolbar: {
placement: toolbar.placement,
background: requireHex(toolbar.background, "invalid_toolbar_background"),
border: requireHex(toolbar.border, "invalid_toolbar_border"),
outline: requireHex(toolbar.outline, "invalid_toolbar_outline"),
minSize: requireNumber(toolbar.minSize, 16, 64, "invalid_toolbar_min_size"),
maxSize: requireNumber(toolbar.maxSize, 32, 160, "invalid_toolbar_max_size"),
lensCount: requireInteger(toolbar.lensCount, 1, 12, "invalid_toolbar_lens_count"),
autoHide: requireBoolean(toolbar.autoHide, "invalid_toolbar_auto_hide"),
},
};
}
function validateDesignProfile(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) throw applicationError("invalid_design_profile");
if (value.schemaVersion !== "0.1.0") throw applicationError("unsupported_design_profile_schema");
if (value.id !== "default" && !/^[0-9a-f-]{36}$/i.test(String(value.id || ""))) throw applicationError("invalid_design_profile_id");
const name = String(value.name || "").trim();
if (!name || name.length > 120) throw applicationError("invalid_design_profile_name");
if (!value.layout || typeof value.layout !== "object" || Array.isArray(value.layout)) throw applicationError("invalid_design_profile_layout");
return { ...value, name };
if (!/^\d+\.\d+\.\d+$/.test(String(value.version || ""))) throw applicationError("invalid_design_profile_version");
if (value.status !== "draft" && value.status !== "published") throw applicationError("invalid_design_profile_status");
if (!isObject(value.timestamps) || !value.timestamps.createdAt || !value.timestamps.updatedAt) throw applicationError("invalid_design_profile_timestamps");
if (value.status === "published" && !value.timestamps.publishedAt) throw applicationError("missing_design_profile_published_at");
return { ...value, name, layout: validateDesignProfileLayout(value.layout) };
}
async function ensureDefaultDesignProfile() {
@ -115,8 +247,58 @@ async function writeDesignProfile(profile) {
await rename(tempPath, targetPath);
}
function designProfileSummary(profile) {
return { id: profile.id, name: profile.name, version: profile.version, theme: profile.layout?.theme === "light" ? "light" : "dark", updatedAt: profile.timestamps.updatedAt };
async function writeDesignProfileRelease(profile) {
const release = validateDesignProfile({
...profile,
status: "published",
timestamps: { ...profile.timestamps, publishedAt: new Date().toISOString() },
});
await mkdir(designProfileReleaseDir(release.id), { recursive: true });
try {
await writeFile(designProfileReleasePath(release.id, release.version), `${JSON.stringify(release, null, 2)}\n`, { encoding: "utf8", flag: "wx" });
} catch (error) {
if (error?.code === "EEXIST") throw applicationError("design_profile_version_already_published", 409);
throw error;
}
return release;
}
async function readDesignProfileReleases(id) {
const releases = [];
try {
for (const entry of await readdir(designProfileReleaseDir(id), { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
try { releases.push(validateDesignProfile(JSON.parse(await readFile(join(designProfileReleaseDir(id), entry.name), "utf8")))); } catch { /* skip damaged immutable snapshot */ }
}
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
return releases.sort((left, right) => String(right.version).localeCompare(String(left.version), undefined, { numeric: true }));
}
async function ensureDefaultDesignProfileRelease() {
const profile = validateDesignProfile(JSON.parse(await readFile(designProfilePath("default"), "utf8")));
try { await readFile(designProfileReleasePath(profile.id, profile.version), "utf8"); }
catch (error) { if (error?.code === "ENOENT") await writeDesignProfileRelease(profile); else throw error; }
}
await ensureDefaultDesignProfileRelease();
function designProfileVersionSummary(profile) {
return { version: profile.version, status: profile.status, theme: profile.layout.theme, publishedAt: profile.timestamps.publishedAt };
}
function designProfileSummary(profile, releases = []) {
return {
id: profile.id,
name: profile.name,
version: profile.version,
status: profile.status,
theme: profile.layout.theme,
updatedAt: profile.timestamps.updatedAt,
versions: releases.map(designProfileVersionSummary),
latestPublishedVersion: releases[0]?.version ?? null,
};
}
function nextPatchVersion(version) {
@ -136,6 +318,8 @@ function validateApplicationManifest(value) {
if (!name || name.length > 120) throw applicationError("invalid_application_name");
if (!slug) throw applicationError("invalid_application_slug");
if (!value.designProfile || typeof value.designProfile !== "object") throw applicationError("invalid_design_profile");
// Pre-versioning manifests already represented the stable default 0.6.0 snapshot.
const designProfileStatus = value.designProfile.status === "draft" ? "draft" : "published";
if (value.designProfile.theme !== "dark" && value.designProfile.theme !== "light") throw applicationError("invalid_application_theme");
if (!Array.isArray(value.pages)) throw applicationError("invalid_application_pages");
const pageIds = new Set();
@ -159,6 +343,7 @@ function validateApplicationManifest(value) {
}
return {
...value,
designProfile: { ...value.designProfile, status: designProfileStatus },
metadata: {
...value.metadata,
name,
@ -189,6 +374,7 @@ function createApplicationManifest(input = {}) {
designProfile: {
id: "default",
version: "0.6.0",
status: "published",
theme: input?.theme === "light" ? "light" : "dark",
},
pages: template ? [{
@ -229,6 +415,29 @@ function applicationSummary(manifest) {
};
}
async function assertDesignProfileReference(reference) {
if (!reference || typeof reference !== "object") throw applicationError("invalid_design_profile_reference");
const profileId = String(reference.id || "");
const version = String(reference.version || "");
let profile;
try {
const sourcePath = reference.status === "published"
? designProfileReleasePath(profileId, version)
: designProfilePath(profileId);
profile = validateDesignProfile(JSON.parse(await readFile(sourcePath, "utf8")));
} catch (error) {
if (error?.code === "ENOENT") throw applicationError("design_profile_reference_not_found", 409);
throw error;
}
if (profile.version !== version || profile.layout.theme !== reference.theme) {
throw applicationError("design_profile_reference_mismatch", 409);
}
if (reference.status === "published" && profile.status !== "published") {
throw applicationError("design_profile_reference_not_published", 409);
}
return profile;
}
async function readJsonBody(request, maxBytes = 2 * 1024 * 1024) {
const chunks = [];
let size = 0;
@ -328,7 +537,10 @@ const server = createServer(async (request, response) => {
const profiles = [];
for (const entry of await readdir(designProfilesDir, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
try { profiles.push(designProfileSummary(validateDesignProfile(JSON.parse(await readFile(join(designProfilesDir, entry.name), "utf8"))))); } catch { /* skip damaged draft */ }
try {
const profile = validateDesignProfile(JSON.parse(await readFile(join(designProfilesDir, entry.name), "utf8")));
profiles.push(designProfileSummary(profile, await readDesignProfileReleases(profile.id)));
} catch { /* skip damaged draft */ }
}
profiles.sort((left, right) => left.name.localeCompare(right.name));
json(response, 200, profiles);
@ -375,9 +587,37 @@ const server = createServer(async (request, response) => {
return;
}
const designProfileVersionsMatch = url.pathname.match(/^\/api\/design-profiles\/(default|[0-9a-f-]{36})\/versions$/i);
if (designProfileVersionsMatch && request.method === "GET") {
let current;
try { current = validateDesignProfile(JSON.parse(await readFile(designProfilePath(designProfileVersionsMatch[1]), "utf8"))); }
catch (error) { if (error?.code === "ENOENT") return json(response, 404, { error: "design_profile_not_found" }); throw error; }
const releases = await readDesignProfileReleases(current.id);
json(response, 200, { id: current.id, draft: designProfileVersionSummary(current), published: releases.map(designProfileVersionSummary) });
return;
}
const designProfileVersionMatch = url.pathname.match(/^\/api\/design-profiles\/(default|[0-9a-f-]{36})\/versions\/(\d+\.\d+\.\d+)$/i);
if (designProfileVersionMatch && request.method === "GET") {
try { json(response, 200, validateDesignProfile(JSON.parse(await readFile(designProfileReleasePath(designProfileVersionMatch[1], designProfileVersionMatch[2]), "utf8")))); }
catch (error) { if (error?.code === "ENOENT") return json(response, 404, { error: "design_profile_version_not_found" }); throw error; }
return;
}
const designProfilePublishMatch = url.pathname.match(/^\/api\/design-profiles\/(default|[0-9a-f-]{36})\/publish$/i);
if (designProfilePublishMatch && request.method === "POST") {
let current;
try { current = validateDesignProfile(JSON.parse(await readFile(designProfilePath(designProfilePublishMatch[1]), "utf8"))); }
catch (error) { if (error?.code === "ENOENT") return json(response, 404, { error: "design_profile_not_found" }); throw error; }
const release = await writeDesignProfileRelease(current);
json(response, 201, release);
return;
}
if (url.pathname === "/api/applications" && request.method === "POST") {
const input = await readJsonBody(request);
const manifest = validateApplicationManifest(createApplicationManifest(input));
await assertDesignProfileReference(manifest.designProfile);
await writeApplicationManifest(manifest);
json(response, 201, manifest);
return;
@ -413,6 +653,7 @@ const server = createServer(async (request, response) => {
updatedAt: new Date().toISOString(),
},
});
await assertDesignProfileReference(next.designProfile);
await writeApplicationManifest(next);
json(response, 200, next);
return;