feat: merge accepted frontend design updates

This commit is contained in:
2026-09-12 18:12:25 +08:00
parent cde8f377e2
commit 4fd6d66c0e
9 changed files with 560 additions and 94 deletions
+35 -22
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { MutableRefObject, PointerEvent as ReactPointerEvent, ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { DockSide, FloatingPanelFrame, FloatingPanelLayout } from '../hooks/useFloatingPanels';
import { computeEdgeHighlight, SnapPreview, WorkspaceSize } from '../hooks/usePanelDocking';
import { IconClose } from './Icons';
@@ -205,6 +206,39 @@ export default function FloatingPanel({
// Exclude panels with zero geometry (e.g. a closed-but-persisted leftover)
&& (frames[otherId]?.width || 0) > 0 && (frames[otherId]?.height || 0) > 0));
// Snap geometry is relative to the workspace. Render guides at that same
// level rather than inside the moving panel, whose own position and overflow
// would otherwise offset and clip the preview.
const dockGuide = (highlight || (snapPreview && snapPreview.side)) && workspaceNodeRef?.current
? createPortal(
<>
{highlight && (
<div
className="floating-panel-edge-highlight"
style={{
left: highlight.x,
top: highlight.y,
width: highlight.width,
height: highlight.height,
}}
/>
)}
{snapPreview && snapPreview.side && (
<div
className="floating-panel-snap-guide"
style={{
left: snapPreview.x,
top: snapPreview.y,
width: snapPreview.width,
height: snapPreview.height,
}}
/>
)}
</>,
workspaceNodeRef.current,
)
: null;
return (
<section
className="floating-panel"
@@ -258,28 +292,7 @@ export default function FloatingPanel({
/>
);
})}
{highlight && (
<div
className="floating-panel-edge-highlight"
style={{
left: highlight.x,
top: highlight.y,
width: highlight.width,
height: highlight.height,
}}
/>
)}
{snapPreview && snapPreview.side && (
<div
className="floating-panel-snap-guide"
style={{
left: snapPreview.x,
top: snapPreview.y,
width: snapPreview.width,
height: snapPreview.height,
}}
/>
)}
{dockGuide}
</section>
);
}
+47
View File
@@ -115,6 +115,53 @@ export function normalizeDocument(input: CanvasDocument): CanvasDocument {
};
}
/**
* Return elements in their final paint order: the document background is
* always underneath, followed by canvas layers from bottom to top. Elements
* inside one layer retain their own relative order.
*/
export function orderCanvasElementsByLayer(documentModel: CanvasDocument): CanvasElement[] {
const document = normalizeDocument(documentModel);
const layerOrder = new Map((document.layers || []).map((layer, index) => [layer.id, index]));
return document.elements
.map((element, elementIndex) => ({
element,
elementIndex,
layerIndex: layerOrder.get(element.layerId || '') ?? -1,
}))
.sort((a, b) => a.layerIndex - b.layerIndex || a.elementIndex - b.elementIndex)
.map(({ element }) => element);
}
/**
* Changes the stacking order of an element without allowing it to cross a
* canvas-layer boundary. Canvas-layer ordering is managed separately.
*/
export function moveCanvasElementWithinLayer(
documentModel: CanvasDocument,
elementId: string,
direction: -1 | 1,
): CanvasDocument {
const document = normalizeDocument(documentModel);
const elementIndex = document.elements.findIndex(element => element.id === elementId);
if (elementIndex < 0) return document;
const layerId = document.elements[elementIndex].layerId;
const siblingIndexes = document.elements
.map((element, index) => ({ element, index }))
.filter(({ element }) => element.layerId === layerId)
.map(({ index }) => index);
const siblingIndex = siblingIndexes.indexOf(elementIndex);
const nextSiblingIndex = siblingIndex + direction;
if (nextSiblingIndex < 0 || nextSiblingIndex >= siblingIndexes.length) return document;
const nextElements = [...document.elements];
const swapIndex = siblingIndexes[nextSiblingIndex];
[nextElements[elementIndex], nextElements[swapIndex]] = [nextElements[swapIndex], nextElements[elementIndex]];
return { ...document, elements: nextElements };
}
function normalizeFolders(folders: CanvasLayerFolder[], layers: CanvasLayer[]) {
const layerIds = new Set(layers.map(layer => layer.id));
return folders
+2 -2
View File
@@ -1,6 +1,6 @@
import { CanvasDocument, StickerAsset } from '../types';
import { apiUrl } from './api';
import { hasCanvasBackground, normalizeDocument, pxToMm } from './canvasDocument';
import { hasCanvasBackground, normalizeDocument, orderCanvasElementsByLayer, pxToMm } from './canvasDocument';
import { createZip } from './zip';
export interface SerializeOptions {
@@ -66,7 +66,7 @@ export async function serializeDocument(
parts.push(`<rect width="100%" height="100%" fill="${escapeXml(doc.background)}"/>`);
}
for (const element of doc.elements) {
for (const element of orderCanvasElementsByLayer(doc)) {
const layerId = element.layerId || doc.layers?.[0]?.id;
if (layerFilter && (!layerId || !layerFilter.has(layerId))) continue;
if (!layerFilter && layerId && !visibleLayers.has(layerId)) continue;
+18 -21
View File
@@ -28,7 +28,9 @@ import {
layerIsLocked,
makeId,
mmToPx,
moveCanvasElementWithinLayer,
normalizeDocument,
orderCanvasElementsByLayer,
pxToMm,
TRANSPARENT_BACKGROUND,
} from '../lib/canvasDocument';
@@ -206,6 +208,12 @@ export default function CanvasStudio({
return map;
}, [stickers]);
const visibleCanvasElements = useMemo(
() => orderCanvasElementsByLayer(normalizedDocument)
.filter(element => layers.find(layer => layer.id === element.layerId)?.visible !== false),
[layers, normalizedDocument],
);
useEffect(() => {
const normalized = normalizeDocument(documentModel);
if (JSON.stringify(normalized) !== JSON.stringify(documentModel)) {
@@ -391,26 +399,12 @@ export default function CanvasStudio({
const bringForward = () => {
if (!selectedId) return;
setDocumentModel(prev => {
const index = prev.elements.findIndex(item => item.id === selectedId);
if (index < 0 || index === prev.elements.length - 1) return prev;
const next = [...prev.elements];
const [item] = next.splice(index, 1);
next.splice(index + 1, 0, item);
return { ...prev, elements: next };
});
setDocumentModel(prev => moveCanvasElementWithinLayer(prev, selectedId, 1));
};
const sendBackward = () => {
if (!selectedId) return;
setDocumentModel(prev => {
const index = prev.elements.findIndex(item => item.id === selectedId);
if (index <= 0) return prev;
const next = [...prev.elements];
const [item] = next.splice(index, 1);
next.splice(index - 1, 0, item);
return { ...prev, elements: next };
});
setDocumentModel(prev => moveCanvasElementWithinLayer(prev, selectedId, -1));
};
const handleSvgImport = async (file: File | null) => {
@@ -1060,16 +1054,16 @@ export default function CanvasStudio({
style={{
width: normalizedDocument.width,
height: normalizedDocument.height,
background: normalizedDocument.background,
'--canvas-background': normalizedDocument.background,
transform: `scale(${zoom})`,
}}
} as CSSProperties}
>
{normalizedDocument.elements
.filter(element => layers.find(layer => layer.id === element.layerId)?.visible !== false)
.map(element => (
{visibleCanvasElements
.map((element, stackOrder) => (
<CanvasElementView
key={element.id}
element={element}
stackOrder={stackOrder}
asset={element.type === 'sticker' ? stickerById.get(element.assetId) : undefined}
selected={element.id === selectedId}
locked={layerIsLocked(normalizedDocument, element.layerId)}
@@ -2293,6 +2287,7 @@ async function stickerAssetToMaskSource(asset: StickerAsset): Promise<import('..
function CanvasElementView({
element,
stackOrder,
asset,
selected,
locked,
@@ -2301,6 +2296,7 @@ function CanvasElementView({
onTextChange,
}: {
element: CanvasElement;
stackOrder: number;
asset?: StickerAsset;
selected: boolean;
locked: boolean;
@@ -2314,6 +2310,7 @@ function CanvasElementView({
width: element.width,
height: element.height,
opacity: element.opacity,
zIndex: stackOrder + 1,
transform: `rotate(${element.rotation}deg)`,
};
+80 -25
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import AppSettingsWindow, { type ThemeMode } from '../components/AppSettingsWindow';
import Breadcrumb from '../components/Breadcrumb';
import { BackendAsset, CanvasTemplate } from '../types';
@@ -236,6 +236,9 @@ function TemplateModal({
const next = () => setIdx(i => Math.min(slides.length - 1, i + 1));
const [largeUrl, setLargeUrl] = useState<string>(slides.length > 0 ? assetUrl(slides[safeIdx]) : '');
const [closing, setClosing] = useState(false);
const closeTimerRef = useRef(0);
const pendingActionRef = useRef(() => onClose());
useEffect(() => {
if (slides.length > 0) {
setLargeUrl(assetUrl(slides[safeIdx]));
@@ -248,11 +251,23 @@ function TemplateModal({
return () => { cancelled = true; };
}, [slides, safeIdx, template, stickerById]);
useEffect(() => () => window.clearTimeout(closeTimerRef.current), []);
const requestClose = (action: () => void = onClose) => {
if (closing) return;
pendingActionRef.current = action;
setClosing(true);
closeTimerRef.current = window.setTimeout(() => {
setClosing(false);
pendingActionRef.current();
}, 160);
};
return (
<div className="template-modal-backdrop" onClick={onClose}>
<div className="template-modal" onClick={e => e.stopPropagation()}>
<div className={`template-modal-backdrop${closing ? ' template-modal-exiting' : ''}`} onClick={() => requestClose()}>
<div className={`template-modal${closing ? ' template-modal-exiting' : ''}`} onClick={e => e.stopPropagation()}>
{/* Large preview with prev/next arrows */}
<div className="template-preview large" style={{ position: 'relative' }}>
<div className="template-preview large modal-layer" style={{ position: 'relative', '--modal-layer': 0 } as React.CSSProperties}>
<img src={largeUrl} alt={template.name} />
{slides.length > 1 && (
<>
@@ -270,39 +285,79 @@ function TemplateModal({
)}
</div>
<div className="template-modal-body">
<div className="template-title large">{template.name}</div>
{template.description && <p className="template-description">{template.description}</p>}
<div className="template-meta modal-meta">
<span>{formatMm(template.document.width)} x {formatMm(template.document.height)} mm</span>
<span>{template.document.elements.length} </span>
<span>{formatDate(templateUpdatedAt(template))}</span>
</div>
{/* Thumbnail strip */}
{slides.length > 1 && (
<div className="reference-strip">
{slides.map((asset, i) => (
<TemplateDetailPanel
template={template}
onUse={() => requestClose(onUse)}
onClose={() => requestClose()}
onDelete={onDelete}
referenceImages={slides.length > 1 ? slides.map((asset, i) => (
<img
key={asset.asset_id}
src={assetUrl(asset)}
alt={asset.name}
className={i === safeIdx ? 'active' : ''}
onClick={() => setIdx(i)}
style={{ cursor: 'pointer', outline: i === safeIdx ? '2px solid var(--color-primary, #6c63ff)' : 'none', borderRadius: 4 }}
/>
))}
)) : undefined}
/>
</div>
</div>
);
}
function TemplateDetailPanel({
template,
referenceImages,
onUse,
onClose,
onDelete,
}: {
template: CanvasTemplate;
referenceImages?: ReactNode;
onUse: () => void;
onClose: () => void;
onDelete: () => void;
}) {
return (
<aside className="template-modal-body template-detail-panel" aria-label="模板详情">
<header className="template-detail-summary modal-layer" style={{ '--modal-layer': 1 } as React.CSSProperties}>
<span className="template-detail-eyebrow"></span>
<h2 className="template-detail-title">{template.name}</h2>
</header>
<section className="template-detail-description modal-layer" style={{ '--modal-layer': 2 } as React.CSSProperties}>
<span className="template-detail-section-label"></span>
<p>{template.description || '未填写简介'}</p>
</section>
<dl className="template-detail-specs modal-layer" style={{ '--modal-layer': 3 } as React.CSSProperties}>
<div className="template-detail-spec">
<dt></dt>
<dd>{formatMm(template.document.width)} × {formatMm(template.document.height)} mm</dd>
</div>
<div className="template-detail-spec">
<dt></dt>
<dd>{template.document.elements.length} </dd>
</div>
<div className="template-detail-spec">
<dt></dt>
<dd>{formatDate(templateUpdatedAt(template))}</dd>
</div>
</dl>
{referenceImages && (
<section className="template-detail-references modal-layer" style={{ '--modal-layer': 4 } as React.CSSProperties}>
<span className="template-detail-section-label"></span>
<div className="reference-strip">{referenceImages}</div>
</section>
)}
<div className="btn-group">
<footer className="template-detail-actions modal-layer" style={{ '--modal-layer': 5 } as React.CSSProperties}>
<button className="btn btn-primary" onClick={onUse}>使</button>
<button className="btn btn-secondary" onClick={onClose}></button>
<button className="btn btn-danger" onClick={onDelete}></button>
</div>
</div>
</div>
</div>
<button className="btn btn-danger template-detail-delete" onClick={onDelete}></button>
</footer>
</aside>
);
}
+251 -14
View File
@@ -36,6 +36,17 @@
--nav-height: 56px;
--panel-width: 240px;
--transition: 200ms cubic-bezier(0.4, 0, 0.2, 1);
--modal-backdrop-duration: 260ms;
--modal-backdrop-opacity: 0.05;
--modal-backdrop-blur: 8px;
--modal-open-duration: 320ms;
--modal-open-delay: 60ms;
--modal-open-easing: cubic-bezier(0.22, 1, 0.36, 1);
--modal-content-duration: 200ms;
--modal-content-delay: 120ms;
--modal-content-stagger: 55ms;
--modal-exit-duration: 160ms;
--modal-exit-easing: cubic-bezier(0.4, 0, 0.2, 1);
--font-main: 'DM Sans', sans-serif;
--font-mono: 'DM Mono', monospace;
/* 液态玻璃 */
@@ -1577,7 +1588,19 @@ body.resizing {
flex-shrink: 0;
box-shadow: 0 16px 50px rgba(0,0,0,0.18);
transform-origin: center center;
overflow: hidden;
/* 画布背景仅限于本身,设计元素允许越出画布继续编辑。 */
overflow: visible;
isolation: isolate;
background: transparent;
}
.studio-stage::before {
content: '';
position: absolute;
inset: 0;
z-index: 0;
background: var(--canvas-background, #ffffff);
pointer-events: none;
}
.studio-bottombar {
@@ -2285,12 +2308,12 @@ body.resizing {
padding: 28px;
/* 只模糊、不压暗:去掉黑遮罩,保留毛玻璃磨砂。
模糊从 0 缓慢增强到目标值(700ms ease-out),进展不突兀 */
background: rgba(255, 255, 255, 0.04);
backdrop-filter: blur(10px);
animation: modal-backdrop-in 700ms ease-out;
background: rgba(255, 255, 255, var(--modal-backdrop-opacity));
backdrop-filter: blur(var(--modal-backdrop-blur));
animation: modal-backdrop-in var(--modal-backdrop-duration) cubic-bezier(0.22, 1, 0.36, 1);
}
:root[data-theme="dark"] .template-modal-backdrop {
background: rgba(10, 12, 18, 0.14);
background: rgba(10, 12, 18, calc(var(--modal-backdrop-opacity) * 2.4));
}
.template-modal {
width: min(960px, 100%);
@@ -2302,11 +2325,139 @@ body.resizing {
border-radius: var(--radius-lg);
background: var(--bg-panel);
box-shadow: var(--shadow-lg);
animation: template-pop 160ms ease-out;
animation: template-pop var(--modal-open-duration) var(--modal-open-easing) var(--modal-open-delay) backwards;
}
.modal-meta {
margin-top: 10px;
.template-modal-backdrop.template-modal-exiting {
animation: modal-backdrop-out var(--modal-exit-duration) var(--modal-exit-easing) forwards;
}
.template-modal.template-modal-exiting {
animation: template-exit var(--modal-exit-duration) var(--modal-exit-easing);
}
.modal-layer {
animation: modal-layer-in var(--modal-content-duration) var(--modal-open-easing)
calc(var(--modal-content-delay) + var(--modal-content-stagger) * var(--modal-layer, 0)) backwards;
}
.template-modal-exiting .modal-layer {
animation: modal-layer-out var(--modal-exit-duration) var(--modal-exit-easing);
}
.template-detail-panel {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
gap: 16px;
padding: 22px;
overflow-y: auto;
}
.template-detail-summary {
display: grid;
gap: 5px;
}
.template-detail-eyebrow,
.template-detail-section-label {
color: var(--lf-text-faint, var(--text-muted));
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
line-height: 1.2;
text-transform: uppercase;
}
.template-detail-title {
margin: 0;
color: var(--lf-text, var(--text-primary));
font-size: 22px;
line-height: 1.25;
overflow-wrap: anywhere;
}
.template-detail-description {
display: grid;
gap: 8px;
padding: 13px 14px;
border: 1px solid var(--lf-glass-soft, var(--border));
border-radius: var(--radius-md);
background: var(--lf-input-bg, var(--bg-panel-alt));
}
.template-detail-description p {
display: -webkit-box;
margin: 0;
overflow: hidden;
color: var(--lf-text-dim, var(--text-secondary));
font-size: 13px;
line-height: 1.55;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
}
.template-detail-specs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin: 0;
}
.template-detail-spec {
min-width: 0;
padding: 11px 10px;
border: 1px solid var(--lf-glass-soft, var(--border));
border-radius: var(--radius-sm);
background: var(--lf-glass-bg, var(--bg-panel-alt));
}
.template-detail-spec dt {
margin-bottom: 6px;
color: var(--lf-text-faint, var(--text-muted));
font-size: 11px;
}
.template-detail-spec dd {
margin: 0;
overflow: hidden;
color: var(--lf-text, var(--text-primary));
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
}
.template-detail-spec:first-child {
grid-column: 1 / -1;
}
.template-detail-references {
display: grid;
gap: 8px;
}
.template-detail-references .reference-strip {
margin: 0;
}
.template-detail-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: auto;
padding-top: 16px;
border-top: 1px solid var(--lf-glass-soft, var(--border));
}
.template-detail-actions .btn {
min-width: 0;
}
.template-detail-delete {
grid-column: 1 / -1;
}
.reference-strip {
@@ -2324,11 +2475,12 @@ body.resizing {
border-radius: var(--radius-md);
background: var(--bg-panel-alt);
flex-shrink: 0;
transition: outline 0.1s;
cursor: pointer;
transition: border-color 0.1s, outline 0.1s;
}
.reference-strip img.active {
outline: 2px solid var(--color-primary, #6c63ff);
outline: 2px solid var(--accent);
outline-offset: 1px;
}
@@ -2367,11 +2519,57 @@ body.resizing {
@keyframes template-pop {
from {
opacity: 0;
transform: scale(0.96);
transform: translateY(0) scale(0.98);
}
to {
opacity: 1;
transform: scale(1);
transform: translateY(0) scale(1);
}
}
@keyframes modal-layer-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes template-exit {
from {
opacity: 1;
transform: translateY(0) scale(1);
}
to {
opacity: 0;
transform: translateY(0) scale(0.98);
}
}
@keyframes modal-layer-out {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(5px);
}
}
@keyframes modal-backdrop-out {
from {
opacity: 1;
-webkit-backdrop-filter: blur(var(--modal-backdrop-blur));
backdrop-filter: blur(var(--modal-backdrop-blur));
}
to {
opacity: 0;
-webkit-backdrop-filter: blur(0px);
backdrop-filter: blur(0px);
}
}
/* 遮罩只模糊不压暗 → 让模糊从 0 慢慢增强到目标值,而非瞬间全糊(进展缓一点) */
@@ -2383,8 +2581,8 @@ body.resizing {
}
to {
opacity: 1;
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(var(--modal-backdrop-blur));
backdrop-filter: blur(var(--modal-backdrop-blur));
}
}
@@ -2471,6 +2669,27 @@ body.resizing {
.template-masonry {
column-count: 1;
}
.template-modal-backdrop {
padding: 14px;
}
.template-detail-panel {
gap: 14px;
padding: 18px;
}
.template-detail-specs {
grid-template-columns: 1fr;
}
.template-detail-actions {
grid-template-columns: 1fr;
}
.template-detail-delete {
grid-column: auto;
}
}
/* ===== RESIZABLE PANEL HANDLE ===== */
@@ -2574,6 +2793,24 @@ body.resizing .studio-panel {
box-shadow: inset 0 0 0 1px var(--lf-glass-soft), 0 12px 34px var(--lf-shadow-strong);
}
/* 横向 SVG 使用满高白色画布承托,完整图形居中显示且不裁切。 */
.template-home .template-modal .template-preview.large {
align-self: stretch;
aspect-ratio: 4 / 3;
min-height: 300px;
background: #fff;
}
.template-home .template-modal .template-preview.large img {
height: 100%;
max-height: none;
}
/* SVG 常带透明背景,缩略图统一以白底承托,保证预览可见。 */
.template-home .template-detail-references .reference-strip img {
background: #fff;
}
/* 空态与模态玻璃化 */
.template-home .template-empty {
background: var(--lf-glass-bg);
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import ts from 'typescript';
async function loadCanvasDocumentModule() {
const source = await readFile(new URL('../src/lib/canvasDocument.ts', import.meta.url), 'utf8');
const compiled = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
},
}).outputText;
const module = { exports: {} };
new Function('exports', 'module', compiled)(module.exports, module);
return module.exports;
}
const documentWithTwoLayers = {
width: 400,
height: 240,
background: '#ffffff',
layers: [
{ id: 'layer-bottom', name: '底层', visible: true, locked: false },
{ id: 'layer-top', name: '顶层', visible: true, locked: false },
],
layerFolders: [],
elements: [
{ id: 'top-first', type: 'rect', layerId: 'layer-top', x: 0, y: 0, width: 10, height: 10, rotation: 0, opacity: 1, fill: '#f00', stroke: '#f00', strokeWidth: 0 },
{ id: 'bottom-first', type: 'rect', layerId: 'layer-bottom', x: 0, y: 0, width: 10, height: 10, rotation: 0, opacity: 1, fill: '#0f0', stroke: '#0f0', strokeWidth: 0 },
{ id: 'top-second', type: 'rect', layerId: 'layer-top', x: 0, y: 0, width: 10, height: 10, rotation: 0, opacity: 1, fill: '#00f', stroke: '#00f', strokeWidth: 0 },
],
};
test('canvas paint order keeps every element above the background and honors layer order', async () => {
const { orderCanvasElementsByLayer } = await loadCanvasDocumentModule();
assert.deepEqual(
orderCanvasElementsByLayer(documentWithTwoLayers).map(element => element.id),
['bottom-first', 'top-first', 'top-second'],
);
});
test('moving an element only changes its order inside its own canvas layer', async () => {
const { moveCanvasElementWithinLayer } = await loadCanvasDocumentModule();
const moved = moveCanvasElementWithinLayer(documentWithTwoLayers, 'top-first', 1);
assert.deepEqual(
moved.elements.map(element => element.id),
['top-second', 'bottom-first', 'top-first'],
);
});
test('canvas elements remain visible when they extend beyond the document surface', async () => {
const styles = await readFile(new URL('../src/styles.css', import.meta.url), 'utf8');
const stageRule = /\.studio-stage\s*\{([\s\S]*?)\n\}/.exec(styles)?.[1] ?? '';
assert.match(stageRule, /overflow:\s*visible/);
});
+10
View File
@@ -0,0 +1,10 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
test('dock preview is mounted in the workspace rather than inside the moving panel', async () => {
const source = await readFile(new URL('../src/components/FloatingPanel.tsx', import.meta.url), 'utf8');
assert.match(source, /createPortal\(/);
assert.match(source, /workspaceNodeRef\.current,\s*\n\s*\)/);
});
@@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const sourcePath = new URL('../src/pages/TemplateHome.tsx', import.meta.url);
const stylesPath = new URL('../src/styles.css', import.meta.url);
test('template details are rendered through one reusable right-panel component', async () => {
const source = await readFile(sourcePath, 'utf8');
assert.match(source, /function TemplateDetailPanel\(/);
assert.match(source, /<TemplateDetailPanel/);
assert.match(source, /className="[^"]*template-detail-panel/);
});
test('template detail styles separate summary, specifications, and actions', async () => {
const styles = await readFile(stylesPath, 'utf8');
assert.match(styles, /\.template-detail-summary/);
assert.match(styles, /\.template-detail-specs/);
assert.match(styles, /\.template-detail-actions/);
});
test('template detail specifications and actions stack on narrow screens', async () => {
const styles = await readFile(stylesPath, 'utf8');
assert.match(styles, /@media \(max-width: 560px\) \{[\s\S]*?\.template-detail-specs\s*\{\s*grid-template-columns: 1fr;/);
assert.match(styles, /@media \(max-width: 560px\) \{[\s\S]*?\.template-detail-actions\s*\{\s*grid-template-columns: 1fr;/);
});
test('narrow-screen modal keeps the action row reachable under the viewport cap', async () => {
const styles = await readFile(stylesPath, 'utf8');
// The modal caps height with overflow:hidden. On single-column mobile the
// detail panel must keep its own scroll so actions cannot be clipped away.
assert.match(styles, /\.template-modal\s*\{[^}]*max-height:\s*calc\(100vh\s*-\s*56px\);/);
assert.match(styles, /\.template-modal\s*\{[^}]*overflow:\s*hidden;/);
assert.match(styles, /\.template-detail-panel\s*\{[^}]*overflow-y:\s*auto;/);
});
test('modal previews fill their column with a white SVG backdrop and thumbnails stay opaque', async () => {
const styles = await readFile(stylesPath, 'utf8');
assert.match(styles, /\.template-home \.template-modal \.template-preview\.large\s*\{[^}]*align-self: stretch;[^}]*aspect-ratio: 4 \/ 3;[^}]*background: #fff;/);
assert.match(styles, /\.template-home \.template-modal \.template-preview\.large img\s*\{[^}]*height: 100%;[^}]*max-height: none;/);
assert.match(styles, /\.template-home \.template-detail-references \.reference-strip img\s*\{[^}]*background: #fff;/);
});