新增后台Dashboard并修正画板规范图选择

新增后台 Dashboard 默认入口、日周月筛选和运营汇总页签

补充后台 Dashboard 数据契约、接口统计和文档

修正画板角色与图标规范图选择、缓存和提示状态

修复创作页桌面导航类型收窄错误

更新 Codex 环境初始化脚本和相关回归测试记录
This commit is contained in:
2026-06-23 22:41:33 +08:00
parent d693279d8a
commit fbecb3c7cb
33 changed files with 2400 additions and 89 deletions
@@ -540,20 +540,35 @@ describe('ImageCanvasGenerationDialogModel', () => {
characterReferences: [],
};
const sourceLayer = createLayer({ title: '参考图' });
const characterSpecLayer = createLayer({
title: '角色规范',
assetKind: 'spec',
});
expect(assignCharacterSpecReference(characterDialog, sourceLayer)).toEqual(
expect(
assignCharacterSpecReference(characterDialog, characterSpecLayer),
).toEqual(
expect.objectContaining({
status: 'idle',
errorMessage: undefined,
composerOpen: true,
characterSpecReference: expect.objectContaining({
id: 'canvas-layer-source',
label: '参考图',
label: '角色规范',
src: 'data:image/png;base64,source',
resourceId: 'resource-source',
}),
}),
);
expect(assignCharacterSpecReference(characterDialog, sourceLayer)).toBe(
characterDialog,
);
expect(
assignCharacterSpecReference(
characterDialog,
createLayer({ title: '角色形象', assetKind: 'character' }),
),
).toBe(characterDialog);
expect(
appendCharacterReference(characterDialog, sourceLayer),
).toMatchObject({
@@ -108,7 +108,7 @@ function getReferenceMediaType(layer: CanvasLayer) {
: 'image';
}
function isIconSpecReferenceLayer(layer: CanvasLayer) {
export function isIconSpecReferenceLayer(layer: CanvasLayer) {
if (layer.assetKind === 'icon-spec') {
return true;
}
@@ -126,6 +126,13 @@ function isIconSpecReferenceLayer(layer: CanvasLayer) {
);
}
export function isCharacterSpecReferenceLayer(layer: CanvasLayer) {
if (getReferenceMediaType(layer) !== 'image' || layer.assetKind !== 'spec') {
return false;
}
return !isIconSpecReferenceLayer(layer);
}
function appendLimitedSeedanceCanvasReference(
references: NonNullable<GenerateDialogState['generationReferences']>,
layer: CanvasLayer,
@@ -731,7 +738,7 @@ export function assignCharacterSpecReference(
dialog: GenerateDialogState | null,
layer: CanvasLayer,
): GenerateDialogState | null {
return dialog?.mode === 'character' && getReferenceMediaType(layer) === 'image'
return dialog?.mode === 'character' && isCharacterSpecReferenceLayer(layer)
? {
...resetFailedGenerationDialog(dialog),
characterSpecReference: createCanvasLayerReference(layer),
@@ -373,6 +373,36 @@ describe('ImageCanvasGenerationLayerModel', () => {
});
});
it('keeps the spritesheet layer when icon results are missing', () => {
const layers = createIconSpritesheetResultLayers({
generated: {
spritesheetImageSrc: 'data:image/png;base64,sheet',
spritesheetWidth: 512,
spritesheetHeight: 512,
iconImageSrcs: [],
prompt: '图标 prompt',
actualPrompt: '图标 actual prompt',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-icons',
priceMudPoints: 20,
},
iconResults: undefined,
startIndex: 11,
canvasSize: { width: 900, height: 640 },
viewport: { x: 0, y: 0, scale: 1 },
generationInputs: createGenerationInputs(),
});
expect(layers).toHaveLength(1);
expect(layers[0]).toMatchObject({
id: 'layer-icon-spritesheet-11',
title: '图标素材图集 11',
assetKind: 'icon-spritesheet',
src: 'data:image/png;base64,sheet',
});
});
it('creates audio result layers centered on the active placeholder', () => {
const layer = createAudioResultLayer({
generated: {
@@ -43,7 +43,7 @@ type QuickEditResultLayerOptions = {
type IconSpritesheetResultLayerOptions = {
generated: EditorIconSpritesheetGenerationResult;
iconResults: EditorIconSpritesheetIconResult[];
iconResults?: EditorIconSpritesheetIconResult[] | null;
startIndex: number;
canvasSize: CanvasSize;
viewport: CanvasViewport;
@@ -299,7 +299,8 @@ export function createIconSpritesheetResultLayers({
let cursorY = startY;
let rowHeight = 0;
const iconLayers = iconResults.map((icon, index) => {
const normalizedIconResults = Array.isArray(iconResults) ? iconResults : [];
const iconLayers = normalizedIconResults.map((icon, index) => {
const resource = icon.resource;
const asset = icon.asset;
const originalWidth = icon.width || 128;
@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, within } from '@testing-library/react';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type {
@@ -147,6 +147,57 @@ describe('ImageCanvasWorldView', () => {
expect(within(layerButton).queryByAltText('画布图片:角色主图')).toBeNull();
});
it('marks an already decoded cached image as loaded', async () => {
const completeDescriptor = Object.getOwnPropertyDescriptor(
HTMLImageElement.prototype,
'complete',
);
const naturalWidthDescriptor = Object.getOwnPropertyDescriptor(
HTMLImageElement.prototype,
'naturalWidth',
);
Object.defineProperty(HTMLImageElement.prototype, 'complete', {
configurable: true,
get: () => true,
});
Object.defineProperty(HTMLImageElement.prototype, 'naturalWidth', {
configurable: true,
get: () => 128,
});
try {
renderWorldView();
const image = screen.getByAltText('画布图片:角色主图');
await waitFor(() => {
expect(image.className).toContain(
'image-canvas-editor__layer-image--loaded',
);
});
} finally {
if (completeDescriptor) {
Object.defineProperty(
HTMLImageElement.prototype,
'complete',
completeDescriptor,
);
} else {
Reflect.deleteProperty(HTMLImageElement.prototype, 'complete');
}
if (naturalWidthDescriptor) {
Object.defineProperty(
HTMLImageElement.prototype,
'naturalWidth',
naturalWidthDescriptor,
);
} else {
Reflect.deleteProperty(HTMLImageElement.prototype, 'naturalWidth');
}
}
});
it('resolves image layers from objectKey before falling back to src', () => {
useResolvedAssetReadUrlMock.mockImplementation(
(_source: string, options?: { objectKey?: string | null }) => ({
@@ -339,6 +339,10 @@ function stopMediaControlKeyPropagation(
event.stopPropagation();
}
function isImageAlreadyDecoded(image: HTMLImageElement | null) {
return Boolean(image?.complete && image.naturalWidth > 0);
}
function getLayerMetricLabel(layer: CanvasLayer) {
return `${Math.round(layer.originalWidth)} x ${Math.round(
layer.originalHeight,
@@ -636,10 +640,11 @@ function ImageCanvasImageLayer({
refreshKey: layer.taskId ?? layer.resourceId,
},
);
const imageRef = useRef<HTMLImageElement | null>(null);
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
setIsLoaded(false);
setIsLoaded(isImageAlreadyDecoded(imageRef.current));
}, [resolvedUrl]);
const isLoading =
@@ -657,6 +662,7 @@ function ImageCanvasImageLayer({
) : null}
{resolvedUrl ? (
<img
ref={imageRef}
src={resolvedUrl}
alt={`画布图片:${layer.title}`}
decoding="async"
@@ -708,6 +714,7 @@ function ImageCanvasImageSequenceLayer({
currentFrame?.frameIndex ?? frameIndex + 1
}`,
});
const frameImageRef = useRef<HTMLImageElement | null>(null);
const [isFrameLoaded, setIsFrameLoaded] = useState(false);
useEffect(() => {
@@ -716,7 +723,7 @@ function ImageCanvasImageSequenceLayer({
}, [layer.taskId, layer.resourceId, frames.length]);
useEffect(() => {
setIsFrameLoaded(false);
setIsFrameLoaded(isImageAlreadyDecoded(frameImageRef.current));
}, [resolvedUrl]);
useEffect(() => {
@@ -747,6 +754,7 @@ function ImageCanvasImageSequenceLayer({
) : null}
{resolvedUrl ? (
<img
ref={frameImageRef}
src={resolvedUrl}
alt={`画布序列帧:${layer.title}`}
decoding="async"
@@ -1583,6 +1583,44 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect(screen.getByTestId('fit-count').textContent).toBe('1');
});
it('keeps the extracted spritesheet when extracted icon results are missing', async () => {
extractEditorUiDesignAssetsMock.mockResolvedValueOnce({
spritesheetImageSrc: 'data:image/png;base64,ui-sheet',
spritesheetWidth: 512,
spritesheetHeight: 512,
prompt: '仅提取被红色框框选的素材并整理成spritesheet',
actualPrompt: '仅提取被红色框框选的素材并整理成spritesheet',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-ui-assets',
});
render(
<SubmissionWorkflowHarness
initialLayers={[
createLayer({
assetKind: 'ui-design',
objectKey: 'generated/ui-design.png',
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '提取UI素材' }));
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-icon-spritesheet-1:源图 素材图集:-:icon-spritesheet',
);
});
expect(screen.getByTestId('layers').textContent).not.toContain(
'layer-icon-2:',
);
expect(screen.getByTestId('selected').textContent).toBe(
'layer-icon-spritesheet-1',
);
expect(screen.getByTestId('fit-count').textContent).toBe('1');
});
it('renders selected UI marks into the extraction reference image and sends derived extraction pricing', async () => {
extractEditorUiDesignAssetsMock.mockResolvedValueOnce({
spritesheetImageSrc: 'data:image/png;base64,ui-sheet',
@@ -759,7 +759,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
}
const nextLayers = addIconSpritesheetResultLayers(
generated,
generated.iconImageSrcs,
generated.iconImageSrcs ?? [],
generationInputs,
extractionFrame,
undefined,
@@ -868,7 +868,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
}
addIconSpritesheetResultLayers(
generated,
generated.iconImageSrcs,
generated.iconImageSrcs ?? [],
submissionPlan.generationInputs,
canvasCompletionPlaceholder,
canvasDialog.id,
@@ -184,6 +184,20 @@ function GenerationSurfaceHarness() {
>
</button>
<button
type="button"
onClick={() =>
surface.pickCharacterSpecFromLayer(
createLayer({
id: 'layer-character-image',
title: '角色形象 2',
assetKind: 'character',
}),
)
}
>
</button>
<button
type="button"
onClick={() => surface.switchGenerationTool('select')}
@@ -235,6 +249,17 @@ describe('useImageCanvasGenerationSurface', () => {
).toBeTruthy();
});
it('renders a warning toast when a non-spec layer is picked as character spec', () => {
render(<GenerationSurfaceHarness />);
fireEvent.click(screen.getByRole('button', { name: '切换角色' }));
fireEvent.click(screen.getByRole('button', { name: '选择非规范角色图' }));
expect(screen.getByRole('alert').textContent).toBe(
'选择的图片不是角色规范图,请选择生成规范里的角色规范。',
);
});
it('anchors the music menu to the music tool and closes after hover leaves', () => {
vi.useFakeTimers();
try {
@@ -15,6 +15,7 @@ import {
PlatformFloatingMenu,
PlatformFloatingMenuItem,
} from '../common/PlatformFloatingMenu';
import { PlatformRuntimeStatusToast } from '../common/PlatformRuntimeStatusToast';
import type {
EditorAssetSnapshot,
EditorProjectSnapshot,
@@ -338,6 +339,17 @@ export function useImageCanvasGenerationSurface({
const generationComposerNode = (
<>
{generationWorkflow.referencePickWarning ? (
<PlatformRuntimeStatusToast
tone="warning"
surface="solid"
size="sm"
className="image-canvas-editor__reference-pick-toast"
role="alert"
>
{generationWorkflow.referencePickWarning}
</PlatformRuntimeStatusToast>
) : null}
<ImageCanvasGenerationComposerView
specToolWrapRef={specToolWrapRef}
publicationReferenceButtonRef={publicationReferenceButtonRef}
@@ -8,6 +8,7 @@ import type {
CanvasGenerationDialogState,
CanvasLayer,
CanvasTool,
GenerateDialogState,
ImageContextMenuState,
SidebarPanel,
} from './ImageCanvasEditorTypes';
@@ -22,9 +23,18 @@ const generateEditorBackgroundMusicMock = vi.hoisted(() => vi.fn());
const editEditorImageMock = vi.hoisted(() => vi.fn());
const renderCropExpandImageMock = vi.hoisted(() => vi.fn());
vi.mock('../../services/image-editor/editorImageReference', () => ({
resolveEditorImageReferenceDataUrl: vi.fn(async (src: string) => src),
}));
vi.mock('../../services/image-editor/editorImageReference', async () => {
const actual = await vi.importActual<
typeof import('../../services/image-editor/editorImageReference')
>('../../services/image-editor/editorImageReference');
return {
...actual,
resolveEditorImageReferenceDataUrl: vi.fn(async (src: string) => src),
resolveEditorImageReferenceDataUrlForGeneration: vi.fn(
async (src: string) => src,
),
};
});
vi.mock('../../services/image-editor/editorProjectClient', async () => {
const actual = await vi.importActual<
@@ -107,6 +117,7 @@ function GenerationWorkflowHarness({
const fitLayersMockRef = useRef(vi.fn());
const layerCounterRef = useRef(0);
const dialogs = useCanvasGenerationDialogs();
const activeDialogRef = useRef<GenerateDialogState | null>(null);
const workflow = useImageCanvasGenerationWorkflow({
layers,
canvasSize: { width: 900, height: 640 },
@@ -137,6 +148,7 @@ function GenerationWorkflowHarness({
});
const activeDialog = dialogs.generateDialog;
activeDialogRef.current = activeDialog;
const activeCanvasDialog =
activeDialog && 'id' in activeDialog
? (activeDialog as CanvasGenerationDialogState)
@@ -191,6 +203,9 @@ function GenerationWorkflowHarness({
<span data-testid="ui-design-spec-reference">
{activeDialog?.uiDesignSpecReference?.label ?? '-'}
</span>
<span data-testid="reference-pick-warning">
{workflow.referencePickWarning ?? '-'}
</span>
<span data-testid="quick-edit">
{workflow.quickEditPanel
? `${workflow.quickEditPanel.sourceLayerId}:${workflow.quickEditPanel.status}:${workflow.quickEditPanel.prompt || '-'}`
@@ -388,8 +403,9 @@ function GenerationWorkflowHarness({
<button
type="button"
onClick={() => {
if (activeDialog) {
void workflow.submitImageGeneration(activeDialog);
const latestDialog = activeDialogRef.current;
if (latestDialog) {
void workflow.submitImageGeneration(latestDialog);
}
}}
>
@@ -421,6 +437,22 @@ function GenerationWorkflowHarness({
>
</button>
<button
type="button"
onClick={() =>
workflow.pickCharacterSpecFromLayer(
createLayer({
id: 'layer-character-image',
resourceId: 'resource-character-image',
title: '角色形象 2',
src: 'data:image/png;base64,character-image',
assetKind: 'character',
}),
)
}
>
</button>
<button
type="button"
onClick={() =>
@@ -437,6 +469,22 @@ function GenerationWorkflowHarness({
>
</button>
<button
type="button"
onClick={() =>
workflow.pickIconSpecFromLayer(
createLayer({
id: 'layer-icon-image',
resourceId: 'resource-icon-image',
title: '图标素材 2',
src: 'data:image/png;base64,icon-image',
assetKind: 'icon',
}),
)
}
>
</button>
<button
type="button"
onClick={() =>
@@ -768,11 +816,21 @@ describe('useImageCanvasGenerationWorkflow', () => {
fireEvent.click(screen.getByRole('button', { name: '提交生成' }));
await waitFor(() => {
expect(generateEditorSoundEffectMock).toHaveBeenCalledWith({
prompt: '金币跳出音',
model: 'audio1.0',
duration: 8,
});
expect(generateEditorSoundEffectMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '金币跳出音',
model: 'audio1.0',
duration: 8,
generationInputs: {
fields: [
{ title: 'prompt', value: '金币跳出音' },
{ title: 'model', value: 'audio1.0' },
{ title: 'duration', value: '8秒' },
],
references: [],
},
}),
);
});
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
@@ -797,24 +855,22 @@ describe('useImageCanvasGenerationWorkflow', () => {
fireEvent.click(screen.getByRole('button', { name: '填写生成提示词' }));
fireEvent.click(screen.getByRole('button', { name: '提交生成' }));
expect(generateEditorImageMock).toHaveBeenCalledWith({
prompt: '一张生成图',
model: 'gemini-3.1-flash-image-preview',
aspectRatio: '1:1',
imageSize: '1K',
projectId: undefined,
assetFolderId: undefined,
assetKind: undefined,
assetLabel: '生成图片 1',
generationInputs: {
fields: [{ title: '生成提示词', value: '一张生成图' }],
references: [],
},
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith({
prompt: '一张生成图',
model: 'gemini-3.1-flash-image-preview',
aspectRatio: '1:1',
imageSize: '1K',
projectId: undefined,
assetFolderId: undefined,
assetKind: undefined,
assetLabel: '生成图片 1',
generationInputs: {
fields: [{ title: '生成提示词', value: '一张生成图' }],
references: [],
},
});
});
expect(screen.getByTestId('dialog').textContent).toBe(
'generate:generating:closed:-:placeholder',
);
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-generated-1:生成图片 1',
@@ -894,6 +950,26 @@ describe('useImageCanvasGenerationWorkflow', () => {
);
});
it('warns and keeps character spec empty when picked layer is not a spec image', () => {
render(<GenerationWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '打开角色生成' }));
fireEvent.click(screen.getByRole('button', { name: '选择非规范角色图' }));
expect(screen.getByTestId('character-spec-reference').textContent).toBe(
'-',
);
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
'选择的图片不是角色规范图,请选择生成规范里的角色规范。',
);
fireEvent.click(screen.getByRole('button', { name: '选择角色规范' }));
expect(screen.getByTestId('character-spec-reference').textContent).toBe(
'上一张角色规范',
);
expect(screen.getByTestId('reference-pick-warning').textContent).toBe('-');
});
it('restores the last picked character spec from local cache after remounting the editor', () => {
const firstRender = render(<GenerationWorkflowHarness />);
@@ -967,6 +1043,24 @@ describe('useImageCanvasGenerationWorkflow', () => {
);
});
it('warns and keeps icon spec empty when picked layer is not an icon spec image', () => {
render(<GenerationWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '打开图标生成' }));
fireEvent.click(screen.getByRole('button', { name: '选择非图标规范图' }));
expect(screen.getByTestId('icon-spec-reference').textContent).toBe('-');
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
'选择的图片不是图标规范图,请选择生成规范里的图标规范。',
);
fireEvent.click(screen.getByRole('button', { name: '选择图标规范' }));
expect(screen.getByTestId('icon-spec-reference').textContent).toBe(
'上一张图标规范',
);
expect(screen.getByTestId('reference-pick-warning').textContent).toBe('-');
});
it('restores the last picked icon spec from local cache for icon and UI design dialogs', () => {
const firstRender = render(<GenerationWorkflowHarness />);
@@ -56,6 +56,8 @@ import {
createUiDesignGenerationDialogDraft,
createVideoGenerationDialogDraft,
hideGeneratedLayerComposerAfterBlur,
isCharacterSpecReferenceLayer,
isIconSpecReferenceLayer,
updateCharacterAnimationDurationPanel,
updateIconDescriptionsTextInDialog,
updateSpecFormDialogValue,
@@ -111,6 +113,11 @@ const LAST_CHARACTER_SPEC_REFERENCE_CACHE_KEY =
'genarrative.imageCanvas.lastCharacterSpecReference';
const LAST_ICON_SPEC_REFERENCE_CACHE_KEY =
'genarrative.imageCanvas.lastIconSpecReference';
const REFERENCE_PICK_WARNING_DISMISS_MS = 2600;
const INVALID_CHARACTER_SPEC_WARNING =
'选择的图片不是角色规范图,请选择生成规范里的角色规范。';
const INVALID_ICON_SPEC_WARNING =
'选择的图片不是图标规范图,请选择生成规范里的图标规范。';
function appendQuickEditSelectionPrompt(prompt: string, markNumber: number) {
const nextLine = `${markNumber}号红色圈选框里的内容做以下修改:`;
@@ -391,6 +398,9 @@ export function useImageCanvasGenerationWorkflow({
] = useState(false);
const [quickEditPanel, setQuickEditPanel] =
useState<QuickEditPanelState | null>(null);
const [referencePickWarning, setReferencePickWarning] = useState<
string | null
>(null);
const [cropExpandPanel, setCropExpandPanel] =
useState<CropExpandPanelState | null>(null);
const [uiAssetExtractionState, setUiAssetExtractionState] =
@@ -482,6 +492,16 @@ export function useImageCanvasGenerationWorkflow({
}
}, [generateDialog]);
useEffect(() => {
if (!referencePickWarning) {
return undefined;
}
const warningTimer = window.setTimeout(() => {
setReferencePickWarning(null);
}, REFERENCE_PICK_WARNING_DISMISS_MS);
return () => window.clearTimeout(warningTimer);
}, [referencePickWarning]);
const quickEditSourceLayer = quickEditPanel
? (layers.find((layer) => layer.id === quickEditPanel.sourceLayerId) ??
null)
@@ -1093,6 +1113,11 @@ export function useImageCanvasGenerationWorkflow({
const pickCharacterSpecFromLayer = useCallback(
(layer: CanvasLayer) => {
if (!isCharacterSpecReferenceLayer(layer)) {
setReferencePickWarning(INVALID_CHARACTER_SPEC_WARNING);
return;
}
setReferencePickWarning(null);
setGenerateDialog((currentDialog) => {
const nextDialog = assignCharacterSpecReference(currentDialog, layer);
if (
@@ -1147,6 +1172,11 @@ export function useImageCanvasGenerationWorkflow({
const pickIconSpecFromLayer = useCallback(
(layer: CanvasLayer) => {
if (!isIconSpecReferenceLayer(layer)) {
setReferencePickWarning(INVALID_ICON_SPEC_WARNING);
return;
}
setReferencePickWarning(null);
const nextDialog = assignIconSpecReference(generateDialog, layer);
if (nextDialog?.mode === 'icon' && nextDialog.iconSpecReference) {
lastIconSpecReferenceRef.current = nextDialog.iconSpecReference;
@@ -1164,6 +1194,11 @@ export function useImageCanvasGenerationWorkflow({
const pickUiDesignSpecFromLayer = useCallback(
(layer: CanvasLayer) => {
if (!isIconSpecReferenceLayer(layer)) {
setReferencePickWarning(INVALID_ICON_SPEC_WARNING);
return;
}
setReferencePickWarning(null);
const nextDialog = assignUiDesignSpecReference(generateDialog, layer);
if (
nextDialog?.mode === 'ui-design' &&
@@ -1674,6 +1709,8 @@ export function useImageCanvasGenerationWorkflow({
setIsPublicationReferenceMenuOpen,
isPickingPublicationReferenceFromCanvas,
setIsPickingPublicationReferenceFromCanvas,
referencePickWarning,
clearReferencePickWarning: () => setReferencePickWarning(null),
openGenerateDialog,
openSpecDialog,
openCharacterAnimationPanel,
@@ -1752,6 +1789,7 @@ export function useImageCanvasGenerationWorkflow({
isPublicationReferenceMenuOpen,
isSpecMenuOpen,
isPickingPublicationReferenceFromCanvas,
referencePickWarning,
openBackgroundMusicGenerationDialog,
openCharacterAnimationPanel,
openCharacterGenerationDialog,
@@ -327,6 +327,13 @@ const PLATFORM_DESKTOP_NAV_TABS: PlatformNavTab[] = [
'projects',
'profile',
];
const GUEST_DESKTOP_NAV_TABS: PlatformNavTab[] = [
'home',
'category',
'create',
'projects',
'profile',
];
const HIDDEN_DESKTOP_CREATION_NAV_TABS = new Set<PlatformNavTab>([
'home',
'category',
@@ -2903,7 +2910,7 @@ export function RpgEntryHomeView({
() =>
(isAuthenticated
? PLATFORM_DESKTOP_NAV_TABS
: ['home', 'category', 'create', 'projects', 'profile']
: GUEST_DESKTOP_NAV_TABS
).filter((tab) => !HIDDEN_DESKTOP_CREATION_NAV_TABS.has(tab)),
[isAuthenticated],
);
+32 -11
View File
@@ -3202,18 +3202,29 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
display: block;
overflow: hidden;
border-radius: inherit;
background:
linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.62),
transparent
),
rgba(226, 232, 240, 0.72);
animation: image-canvas-generation-scan 1.45s linear infinite;
background: rgba(226, 232, 240, 0.72);
pointer-events: none;
}
.project-gallery__canvas-cover-loading::before,
.image-canvas-editor__layer-image-loading::before {
position: absolute;
top: 0;
bottom: 0;
left: -64%;
width: 64%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.62),
transparent
);
content: '';
transform: translateX(-100%);
animation: image-canvas-generation-scan 1.45s linear infinite;
will-change: transform;
}
.project-gallery__canvas-cover-image {
position: relative;
z-index: 1;
@@ -5771,10 +5782,10 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
@keyframes image-canvas-generation-scan {
from {
transform: translateX(-65%);
transform: translateX(-100%);
}
to {
transform: translateX(65%);
transform: translateX(260%);
}
}
@@ -8379,6 +8390,16 @@ button.image-canvas-editor__reference-chip:disabled {
transform: translateX(-50%);
}
.image-canvas-editor__reference-pick-toast {
position: absolute;
top: 1rem;
left: 50%;
z-index: 72;
max-width: min(24rem, calc(100% - 2rem));
transform: translateX(-50%);
pointer-events: none;
}
.image-canvas-editor__generate-submit {
justify-self: end;
}
+25
View File
@@ -31,6 +31,31 @@ function getCssBlock(source: string, selector: string) {
}
describe('index stylesheet unread dots', () => {
it('keeps image canvas loading shimmer clipped inside media frames', () => {
const css = readIndexCss();
const loadingBlock = getCssBlock(
css,
'.project-gallery__canvas-cover-loading,\n.image-canvas-editor__layer-image-loading',
);
const shimmerBlock = getCssBlock(
css,
'.project-gallery__canvas-cover-loading::before,\n.image-canvas-editor__layer-image-loading::before',
);
const keyframesBlock = getCssBlock(css, '@keyframes image-canvas-generation-scan');
expect(loadingBlock).toContain('overflow: hidden;');
expect(loadingBlock).toContain('background: rgba(226, 232, 240, 0.72);');
expect(loadingBlock).not.toContain('animation: image-canvas-generation-scan');
expect(shimmerBlock).toContain('left: -64%;');
expect(shimmerBlock).toContain('width: 64%;');
expect(shimmerBlock).toContain('animation: image-canvas-generation-scan');
expect(shimmerBlock).toContain('will-change: transform;');
expect(keyframesBlock).toContain('translateX(-100%)');
expect(keyframesBlock).toContain('translateX(260%)');
});
it('keeps creation home primary rail styling separate from active tab styling', () => {
const css = readIndexCss();