Polish Visual Editor command ribbon

This commit is contained in:
Victor Wiebe 2026-08-07 15:33:50 -04:00
parent 60273d7528
commit 24f04b9fc3
10 changed files with 519 additions and 39 deletions

View File

@ -28,7 +28,7 @@ The slice files remain the detailed implementation plans. When an older list con
| [Slice 7a](SLICE7a.md) | Local authentication, RBAC, and application publishing | Complete | Implementation, security validation, manual acceptance, cleanup, and explicit sign-off passed | | [Slice 7a](SLICE7a.md) | Local authentication, RBAC, and application publishing | Complete | Implementation, security validation, manual acceptance, cleanup, and explicit sign-off passed |
| [Slice 7b](SLICE7b.md) | Browser-based first-run administrator setup | Complete | Fresh-install setup, takeover prevention, recovery validation, manual acceptance, and explicit sign-off passed | | [Slice 7b](SLICE7b.md) | Browser-based first-run administrator setup | Complete | Fresh-install setup, takeover prevention, recovery validation, manual acceptance, and explicit sign-off passed |
| [Slice 7c](SLICE7c.md) | Multi-page applications and variable scope | Complete | Page authoring, scoped runtime, deep links, security validation, and explicit sign-off | | [Slice 7c](SLICE7c.md) | Multi-page applications and variable scope | Complete | Page authoring, scoped runtime, deep links, security validation, and explicit sign-off |
| [Slice 7d](SLICE7d.md) | Visual Editor professional command ribbon | Planned | Ribbon implementation, responsive/accessibility validation, manual visual acceptance, and explicit sign-off | | [Slice 7d](SLICE7d.md) | Visual Editor professional command ribbon | Complete | None |
| [Slice 8](SLICE8.md) | Documentation and release packaging | Partial | Unified roadmap complete; broader documentation ownership, reconciliation, guides, and release packaging remain | | [Slice 8](SLICE8.md) | Documentation and release packaging | Partial | Unified roadmap complete; broader documentation ownership, reconciliation, guides, and release packaging remain |
| [Slice 9](SLICE9.md) | OIDC and enterprise SSO | Post-MVP | Begins after Slice 7a; provider and provisioning decisions remain | | [Slice 9](SLICE9.md) | OIDC and enterprise SSO | Post-MVP | Begins after Slice 7a; provider and provisioning decisions remain |
@ -278,10 +278,10 @@ Controlled orchestration is not required in the v0.1.0 demonstration. Execution
### Slice 7d — Visual Editor command ribbon ### Slice 7d — Visual Editor command ribbon
- [ ] Replace dense native-looking Visual Editor command rows with a grouped professional ribbon. - [x] Replace dense native-looking Visual Editor command rows with a grouped professional ribbon.
- [ ] Distinguish project, page, page-settings, lifecycle, primary, disabled, and destructive controls. - [x] Distinguish project, page, page-settings, lifecycle, primary, disabled, and destructive controls.
- [ ] Preserve Slice 7c behavior while improving desktop responsiveness, keyboard access, focus, and visual hierarchy. - [x] Preserve Slice 7c behavior while improving desktop responsiveness, keyboard access, focus, and visual hierarchy.
- [ ] Complete automated regression and accessibility checks plus manual visual acceptance and explicit sign-off. - [x] Complete automated regression and accessibility checks plus manual visual acceptance and explicit sign-off.
## 8. Documentation, Packaging, and Release ## 8. Documentation, Packaging, and Release

View File

@ -26,7 +26,7 @@ This matrix maps every release-critical requirement area in `docs/REQUIREMENTS.m
| R16 | Immutable standalone published applications with public/authenticated visibility and server-owned published action execution | Slice 7a | Server-snapshot integration coverage and accepted public/restricted publishing workflows | Accepted | | R16 | Immutable standalone published applications with public/authenticated visibility and server-owned published action execution | Slice 7a | Server-snapshot integration coverage and accepted public/restricted publishing workflows | Accepted |
| R17 | A fresh installation creates its initial administrator through a secure browser first-run flow without requiring Docker commands | Slice 7b | Atomic setup/security integration tests, frontend setup/password tests, recovery verification, and accepted manual workflow | Accepted | | R17 | A fresh installation creates its initial administrator through a secure browser first-run flow without requiring Docker commands | Slice 7b | Atomic setup/security integration tests, frontend setup/password tests, recovery verification, and accepted manual workflow | Accepted |
| R18 | Authored and published applications support multiple deep-linked pages with page-local components and explicit global/page runtime-variable scope | Slice 7c | Schema/editor/runtime/publishing tests, multi-page browser E2E, security validation, and manual acceptance | Accepted | | R18 | Authored and published applications support multiple deep-linked pages with page-local components and explicit global/page runtime-variable scope | Slice 7c | Schema/editor/runtime/publishing tests, multi-page browser E2E, security validation, and manual acceptance | Accepted |
| R19 | The Visual Editor presents project, page, page-setting, and lifecycle commands in a professional, accessible, responsive command ribbon | Slice 7d | Ribbon component/regression/accessibility checks and manual visual acceptance | Planned | | R19 | The Visual Editor presents project, page, page-setting, and lifecycle commands in a professional, accessible, responsive command ribbon | Slice 7d | 28 frontend suites / 528 tests, production build, and accepted manual visual validation | Accepted |
## Approved acceptance workflows ## Approved acceptance workflows

View File

@ -11,6 +11,15 @@
flex-shrink: 0; flex-shrink: 0;
} }
.toolbarRibbon {
min-height: 52px;
height: auto;
padding-top: 7px;
padding-bottom: 7px;
background: linear-gradient(180deg, #202832 0%, #171d24 100%);
box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.04);
}
.nameArea { .nameArea {
display: flex; display: flex;
align-items: center; align-items: center;
@ -40,6 +49,15 @@
.nameButton:hover { .nameButton:hover {
border-color: #484f58; border-color: #484f58;
background: rgba(255, 255, 255, 0.05);
}
.nameButton:focus-visible,
.btn:focus-visible,
.pickerClose:focus-visible,
.pickerItem:focus-visible {
outline: 2px solid #79c0ff;
outline-offset: 2px;
} }
.nameInput { .nameInput {
@ -77,16 +95,27 @@
flex-shrink: 0; flex-shrink: 0;
} }
.toolbarRibbon .actions {
padding-left: 14px;
border-left: 1px solid #3b4652;
}
.btn { .btn {
padding: 5px 12px; display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 32px;
padding: 6px 13px;
background: #21262d; background: #21262d;
border: 1px solid #30363d; border: 1px solid #30363d;
border-radius: 4px; border-radius: 6px;
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
color: #c9d1d9; color: #c9d1d9;
cursor: pointer; cursor: pointer;
transition: background 0.1s, border-color 0.1s; box-shadow: 0 1px 1px rgba(0, 0, 0, 0.18);
transition: background 0.12s, border-color 0.12s, transform 0.12s;
white-space: nowrap; white-space: nowrap;
} }
@ -95,14 +124,22 @@
border-color: #8b949e; border-color: #8b949e;
} }
.btn:active:not(:disabled) { transform: translateY(1px); }
.btnIcon {
font-size: 14px;
line-height: 1;
opacity: 0.9;
}
.btn:disabled { .btn:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
.btnPrimary { .btnPrimary {
background: #1f6feb; background: linear-gradient(180deg, #2f81f7 0%, #1f6feb 100%);
border-color: #1f6feb; border-color: #438ef7;
color: #ffffff; color: #ffffff;
} }

View File

@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
import { useProject } from '../../context/ProjectContext'; import { useProject } from '../../context/ProjectContext';
import styles from './ProjectToolbar.module.css'; import styles from './ProjectToolbar.module.css';
function ProjectToolbar(): React.ReactElement { function ProjectToolbar({ variant = 'default' }: { variant?: 'default' | 'ribbon' }): React.ReactElement {
const { const {
projectRowId, projectRowId,
projectName, projectName,
@ -61,7 +61,7 @@ function ProjectToolbar(): React.ReactElement {
return ( return (
<> <>
{/* ── Toolbar strip ─────────────────────────────────────────── */} {/* ── Toolbar strip ─────────────────────────────────────────── */}
<div className={styles.toolbar}> <div className={[styles.toolbar, variant === 'ribbon' ? styles.toolbarRibbon : ''].join(' ')} aria-label="Project commands">
{/* Project name — click to rename */} {/* Project name — click to rename */}
<div className={styles.nameArea}> <div className={styles.nameArea}>
{editingName ? ( {editingName ? (
@ -97,7 +97,7 @@ function ProjectToolbar(): React.ReactElement {
disabled={isLoading || isSaving} disabled={isLoading || isSaving}
title="Create a new project (current unsaved changes will be lost)" title="Create a new project (current unsaved changes will be lost)"
> >
New <span className={styles.btnIcon} aria-hidden="true"></span> New
</button> </button>
{/* Save */} {/* Save */}
@ -107,7 +107,7 @@ function ProjectToolbar(): React.ReactElement {
disabled={isSaving || isLoading} disabled={isSaving || isLoading}
title={projectRowId === null ? 'Save project to backend' : 'Update project on backend'} title={projectRowId === null ? 'Save project to backend' : 'Update project on backend'}
> >
{isSaving ? 'Saving…' : (projectRowId === null ? 'Save' : 'Update')} <span className={styles.btnIcon} aria-hidden="true"></span> {isSaving ? 'Saving…' : (projectRowId === null ? 'Save' : 'Update')}
</button> </button>
{/* Load */} {/* Load */}
@ -117,7 +117,7 @@ function ProjectToolbar(): React.ReactElement {
disabled={isLoading || isSaving} disabled={isLoading || isSaving}
title="Load an existing project from the backend" title="Load an existing project from the backend"
> >
{isLoading ? 'Loading…' : 'Load'} <span className={styles.btnIcon} aria-hidden="true"></span> {isLoading ? 'Loading…' : 'Load'}
</button> </button>
</div> </div>
</div> </div>

View File

@ -40,4 +40,19 @@ describe('PageEventEditor', () => {
}); });
expect(onChange).toHaveBeenCalledWith([]); expect(onChange).toHaveBeenCalledWith([]);
}); });
test('configures a distinct onEnter lifecycle action', () => {
const onChange = jest.fn();
act(() => root.render(<PageEventEditor actions={actions} events={[{ event: 'onLoad', actionId: 'action_a' }]} onChange={onChange} />));
const select = container.querySelector<HTMLSelectElement>('#page-onenter-action')!;
expect(select.getAttribute('aria-label')).toBe('On enter action');
act(() => {
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')!.set!.call(select, 'action_b');
select.dispatchEvent(new Event('change', { bubbles: true }));
});
expect(onChange).toHaveBeenCalledWith([
{ event: 'onLoad', actionId: 'action_a' },
{ event: 'onEnter', actionId: 'action_b' },
]);
});
}); });

View File

@ -24,7 +24,7 @@ export default function PageEventEditor({ events, actions, onChange }: Props): R
{actions.map((action) => <option key={action.id} value={action.id}>{action.name} ({action.id})</option>)} {actions.map((action) => <option key={action.id} value={action.id}>{action.name} ({action.id})</option>)}
</select> </select>
<label className={styles.pageEventLabel} htmlFor="page-onenter-action">On enter</label> <label className={styles.pageEventLabel} htmlFor="page-onenter-action">On enter</label>
<select id="page-onenter-action" className={styles.pageEventSelect} value={enterActionId} onChange={event => setEnter(event.target.value)}> <select id="page-onenter-action" aria-label="On enter action" className={styles.pageEventSelect} value={enterActionId} onChange={event => setEnter(event.target.value)}>
<option value="">No action</option> <option value="">No action</option>
{actions.map(action => <option key={action.id} value={action.id}>{action.name} ({action.id})</option>)} {actions.map(action => <option key={action.id} value={action.id}>{action.name} ({action.id})</option>)}
</select> </select>

View File

@ -0,0 +1,74 @@
import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import PageManager from './PageManager';
import { useProject } from '../../context/ProjectContext';
jest.mock('../../context/ProjectContext', () => ({ useProject: jest.fn() }));
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const mockedUseProject = useProject as jest.MockedFunction<typeof useProject>;
describe('Visual Editor page command ribbon', () => {
let container: HTMLDivElement;
let root: Root;
const commands = {
setActivePage: jest.fn(), addPage: jest.fn(), duplicatePage: jest.fn(),
updatePage: jest.fn(), movePage: jest.fn(), deletePage: jest.fn(), setDefaultPage: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
mockedUseProject.mockReturnValue({
doc: {
schemaVersion: '0.1.0',
project: {
id: 'project', name: 'Ribbon test', actions: [], bindings: [], variables: {},
settings: { defaultPageId: 'home' },
pages: [
{ id: 'home', name: 'Home', slug: 'home', order: 0, showInNavigation: true, components: [] },
{ id: 'details', name: 'Details', slug: 'details', order: 1, showInNavigation: true, components: [] },
],
},
},
activePageId: 'home',
...commands,
} as unknown as ReturnType<typeof useProject>);
});
afterEach(() => { act(() => root.unmount()); container.remove(); });
test('presents clearly named command groups and page state', () => {
act(() => root.render(<PageManager events={[]} actions={[]} onEventsChange={jest.fn()} />));
const ribbon = container.querySelector('[aria-label="Visual Editor command ribbon"]');
expect(ribbon).not.toBeNull();
expect(ribbon?.textContent).toContain('Pages');
expect(ribbon?.textContent).toContain('Page settings');
expect(ribbon?.textContent).toContain('Lifecycle');
expect(ribbon?.textContent).toContain('Page actions');
expect(container.querySelector<HTMLSelectElement>('[aria-label="Active page"]')?.value).toBe('home');
expect(Array.from(container.querySelectorAll('button')).find(button => button.textContent?.includes('Default page'))?.disabled).toBe(true);
expect(container.querySelector<HTMLButtonElement>('[aria-label="Move page left"]')?.disabled).toBe(true);
expect(container.querySelector<HTMLButtonElement>('[aria-label="Move page right"]')?.disabled).toBe(false);
});
test('wires page commands and fields to existing canonical operations', () => {
act(() => root.render(<PageManager events={[]} actions={[]} onEventsChange={jest.fn()} />));
const buttons = Array.from(container.querySelectorAll('button'));
act(() => buttons.find(button => button.textContent?.includes('Add page'))?.click());
act(() => buttons.find(button => button.textContent?.includes('Duplicate'))?.click());
act(() => container.querySelector<HTMLButtonElement>('[aria-label="Move page right"]')?.click());
expect(commands.addPage).toHaveBeenCalledTimes(1);
expect(commands.duplicatePage).toHaveBeenCalledWith('home');
expect(commands.movePage).toHaveBeenCalledWith('home', 1);
const name = container.querySelector<HTMLInputElement>('[aria-label="Page name"]')!;
act(() => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(name, 'Start');
name.dispatchEvent(new Event('change', { bubbles: true }));
});
expect(commands.updatePage).toHaveBeenCalledWith('home', { name: 'Start' });
});
});

View File

@ -1,8 +1,83 @@
import React from 'react'; import React from 'react';
import { useProject } from '../../context/ProjectContext'; import { useProject } from '../../context/ProjectContext';
import type { ComponentEvent, RestAction } from '../../types/project';
import PageEventEditor from './PageEventEditor';
import styles from './VisualEditor.module.css';
export default function PageManager():React.ReactElement{ type Props = {
const{doc,activePageId,setActivePage,addPage,duplicatePage,updatePage,movePage,deletePage,setDefaultPage}=useProject(); events: ComponentEvent[] | undefined;
const pages=[...doc.project.pages].sort((a,b)=>(a.order??0)-(b.order??0)); actions: RestAction[];
return <section aria-label="Project pages" style={{padding:'10px 12px',borderBottom:'1px solid #d0d7de',background:'#f6f8fa'}}><div style={{display:'flex',gap:8,alignItems:'center',flexWrap:'wrap'}}><strong>Pages</strong><select aria-label="Active page" value={activePageId} onChange={e=>setActivePage(e.target.value)}>{pages.map(page=><option key={page.id} value={page.id}>{page.name}{doc.project.settings.defaultPageId===page.id||(!doc.project.settings.defaultPageId&&pages[0].id===page.id)?' (default)':''}</option>)}</select><button type="button" onClick={addPage}>+ Add page</button><button type="button" onClick={()=>duplicatePage(activePageId)}>Duplicate</button><button type="button" disabled={pages.findIndex(p=>p.id===activePageId)===0} onClick={()=>movePage(activePageId,-1)}>Move left</button><button type="button" disabled={pages.findIndex(p=>p.id===activePageId)===pages.length-1} onClick={()=>movePage(activePageId,1)}>Move right</button><button type="button" onClick={()=>setDefaultPage(activePageId)}>Set default</button><button type="button" disabled={pages.length===1} onClick={()=>{const page=pages.find(p=>p.id===activePageId);if(page&&window.confirm(`Delete page "${page.name}"? Page components and page-scoped variables will be removed.`))deletePage(activePageId)}}>Delete page</button></div>{pages.filter(page=>page.id===activePageId).map(page=><div key={page.id} style={{display:'flex',gap:10,alignItems:'center',marginTop:10,flexWrap:'wrap'}}><label>Name <input value={page.name} onChange={e=>updatePage(page.id,{name:e.target.value})}/></label><label>URL slug <input pattern="[a-z0-9]+(?:-[a-z0-9]+)*" value={page.slug??page.name.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'')} onChange={e=>updatePage(page.id,{slug:e.target.value})}/></label><label><input type="checkbox" checked={page.showInNavigation!==false} onChange={e=>updatePage(page.id,{showInNavigation:e.target.checked})}/> Show in navigation</label><code>{page.id}</code></div>)}</section>; onEventsChange: (events: ComponentEvent[]) => void;
};
function CommandIcon({ children }: { children: React.ReactNode }): React.ReactElement {
return <span className={styles.commandIcon} aria-hidden="true">{children}</span>;
}
export default function PageManager({ events, actions, onEventsChange }: Props): React.ReactElement {
const {
doc, activePageId, setActivePage, addPage, duplicatePage,
updatePage, movePage, deletePage, setDefaultPage,
} = useProject();
const pages = [...doc.project.pages].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const activeIndex = pages.findIndex((page) => page.id === activePageId);
const activePage = pages[activeIndex] ?? pages[0];
const defaultPageId = doc.project.settings.defaultPageId ?? pages[0]?.id;
const isDefault = activePage?.id === defaultPageId;
if (!activePage) return <></>;
const confirmDelete = () => {
if (window.confirm(`Delete page "${activePage.name}"? Page components and page-scoped variables will be removed.`)) {
deletePage(activePage.id);
}
};
return (
<section className={styles.commandRibbon} aria-label="Visual Editor command ribbon">
<div className={styles.ribbonGroups}>
<div className={[styles.ribbonGroup, styles.pagesGroup].join(' ')} role="group" aria-labelledby="pages-group-label">
<div className={styles.ribbonGroupBody}>
<label className={styles.pagePickerLabel}>
<span className={styles.srOnly}>Active page</span>
<select className={styles.pagePicker} aria-label="Active page" value={activePageId} onChange={(event) => setActivePage(event.target.value)}>
{pages.map((page) => <option key={page.id} value={page.id}>{page.name}{defaultPageId === page.id ? ' (default)' : ''}</option>)}
</select>
</label>
<button type="button" className={styles.commandButton} onClick={addPage}><CommandIcon></CommandIcon><span>Add page</span></button>
<button type="button" className={styles.commandButton} onClick={() => duplicatePage(activePageId)}><CommandIcon></CommandIcon><span>Duplicate</span></button>
<div className={styles.commandPair} aria-label="Reorder page">
<button type="button" className={styles.iconButton} disabled={activeIndex === 0} onClick={() => movePage(activePageId, -1)} title={activeIndex === 0 ? 'This page is already first' : 'Move page left'} aria-label="Move page left"></button>
<button type="button" className={styles.iconButton} disabled={activeIndex === pages.length - 1} onClick={() => movePage(activePageId, 1)} title={activeIndex === pages.length - 1 ? 'This page is already last' : 'Move page right'} aria-label="Move page right"></button>
</div>
</div>
<div className={styles.ribbonGroupLabel} id="pages-group-label">Pages</div>
</div>
<div className={[styles.ribbonGroup, styles.settingsGroup].join(' ')} role="group" aria-labelledby="settings-group-label">
<div className={styles.ribbonGroupBody}>
<label className={styles.ribbonField}><span>Name</span><input aria-label="Page name" value={activePage.name} onChange={(event) => updatePage(activePage.id, { name: event.target.value })} /></label>
<label className={styles.ribbonField}><span>URL slug</span><input aria-label="Page URL slug" pattern="[a-z0-9]+(?:-[a-z0-9]+)*" value={activePage.slug ?? activePage.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')} onChange={(event) => updatePage(activePage.id, { slug: event.target.value })} /></label>
<div className={styles.pageSettingActions}>
<button type="button" className={[styles.commandButton, isDefault ? styles.commandButtonSelected : ''].join(' ')} disabled={isDefault} onClick={() => setDefaultPage(activePageId)} title={isDefault ? 'This is the default page' : 'Open this page when the application starts'}><CommandIcon></CommandIcon><span>{isDefault ? 'Default page' : 'Set default'}</span></button>
<label className={styles.toggleControl}><input type="checkbox" checked={activePage.showInNavigation !== false} onChange={(event) => updatePage(activePage.id, { showInNavigation: event.target.checked })} /><span className={styles.toggleTrack} aria-hidden="true" /><span>In navigation</span></label>
</div>
</div>
<div className={styles.ribbonGroupLabel} id="settings-group-label">Page settings <code>{activePage.id}</code></div>
</div>
<div className={[styles.ribbonGroup, styles.lifecycleGroup].join(' ')} role="group" aria-labelledby="lifecycle-group-label">
<div className={styles.ribbonGroupBody}><PageEventEditor events={events} actions={actions} onChange={onEventsChange} /></div>
<div className={styles.ribbonGroupLabel} id="lifecycle-group-label">Lifecycle</div>
</div>
<div className={[styles.ribbonGroup, styles.dangerGroup].join(' ')} role="group" aria-labelledby="danger-group-label">
<div className={styles.ribbonGroupBody}>
<button type="button" className={styles.dangerCommand} disabled={pages.length === 1} onClick={confirmDelete} title={pages.length === 1 ? 'A project must contain at least one page' : `Delete ${activePage.name}`}><CommandIcon>×</CommandIcon><span>Delete page</span></button>
</div>
<div className={styles.ribbonGroupLabel} id="danger-group-label">Page actions</div>
</div>
</div>
</section>
);
} }

View File

@ -22,13 +22,286 @@
color: #8b949e; color: #8b949e;
} }
/* ── Visual Editor command ribbon ────────────────────────────────── */
.commandRibbon {
--ribbon-border: #cbd5e1;
--ribbon-control-border: #b8c3d1;
--ribbon-focus: #2563eb;
flex-shrink: 0;
padding: 8px 10px 7px;
overflow-x: hidden;
background: linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
border-bottom: 1px solid var(--ribbon-border);
box-shadow: 0 2px 5px rgba(15, 23, 42, 0.07);
}
.ribbonGroups {
display: flex;
flex-wrap: wrap;
align-items: stretch;
gap: 0;
row-gap: 8px;
width: 100%;
}
.ribbonGroup {
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 76px;
padding: 0 12px;
border-right: 1px solid var(--ribbon-border);
}
.ribbonGroup:first-child { padding-left: 2px; }
.ribbonGroup:last-child { border-right: 0; }
.ribbonGroupBody {
display: flex;
align-items: center;
gap: 7px;
flex: 1;
}
.ribbonGroupLabel {
min-height: 17px;
padding-top: 4px;
color: #64748b;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.055em;
line-height: 1;
text-align: center;
text-transform: uppercase;
}
.ribbonGroupLabel code {
margin-left: 5px;
color: #94a3b8;
font-size: 9px;
font-weight: 500;
letter-spacing: 0;
text-transform: none;
}
.pagesGroup { min-width: 420px; }
.settingsGroup { min-width: 555px; }
.lifecycleGroup { min-width: 360px; }
.dangerGroup { min-width: 112px; }
.pagePicker {
width: 158px;
min-height: 34px;
padding: 6px 30px 6px 10px;
border: 1px solid #8da2ba;
border-radius: 6px;
background: #ffffff;
color: #172033;
font-size: 12px;
font-weight: 650;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
}
.commandButton,
.iconButton,
.dangerCommand {
border: 1px solid var(--ribbon-control-border);
border-radius: 6px;
background: linear-gradient(180deg, #ffffff 0%, #f1f5f9 100%);
color: #243247;
font: inherit;
font-size: 11px;
font-weight: 650;
cursor: pointer;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
transition: border-color 120ms ease, background 120ms ease, box-shadow 120ms ease, transform 120ms ease;
}
.commandButton {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
min-height: 34px;
padding: 6px 9px;
white-space: nowrap;
}
.commandIcon {
color: #47627f;
font-size: 15px;
font-weight: 500;
line-height: 1;
}
.commandButton:hover:not(:disabled),
.iconButton:hover:not(:disabled) {
border-color: #6b8db3;
background: #ffffff;
box-shadow: 0 2px 5px rgba(37, 99, 235, 0.12);
}
.commandButton:active:not(:disabled),
.iconButton:active:not(:disabled),
.dangerCommand:active:not(:disabled) { transform: translateY(1px); }
.commandButton:disabled,
.iconButton:disabled,
.dangerCommand:disabled {
border-color: #d7dee8;
background: #eef2f6;
color: #99a5b5;
cursor: not-allowed;
box-shadow: none;
}
.commandButtonSelected,
.commandButtonSelected:disabled {
border-color: #93c5fd;
background: #dbeafe;
color: #1d4ed8;
opacity: 1;
}
.commandButtonSelected .commandIcon { color: #d97706; }
.commandPair {
display: inline-flex;
overflow: hidden;
border-radius: 6px;
}
.iconButton {
width: 31px;
min-height: 34px;
border-radius: 0;
font-size: 16px;
box-shadow: none;
}
.iconButton + .iconButton { margin-left: -1px; }
.iconButton:first-child { border-radius: 6px 0 0 6px; }
.iconButton:last-child { border-radius: 0 6px 6px 0; }
.ribbonField {
display: grid;
gap: 3px;
color: #64748b;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.ribbonField input,
.pageEventSelect {
box-sizing: border-box;
height: 32px;
padding: 5px 8px;
border: 1px solid var(--ribbon-control-border);
border-radius: 5px;
background: #ffffff;
color: #172033;
font-size: 11px;
font-weight: 500;
outline: none;
box-shadow: inset 0 1px 1px rgba(15, 23, 42, 0.035);
}
.ribbonField input { width: 145px; }
.pageSettingActions {
display: grid;
align-items: center;
gap: 4px;
}
.pageSettingActions .commandButton { min-height: 28px; padding-block: 3px; }
.toggleControl {
display: inline-flex;
align-items: center;
gap: 5px;
color: #475569;
font-size: 10px;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
}
.toggleControl input {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
opacity: 0;
}
.toggleTrack {
position: relative;
width: 26px;
height: 14px;
border-radius: 999px;
background: #94a3b8;
transition: background 120ms ease;
}
.toggleTrack::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 10px;
height: 10px;
border-radius: 50%;
background: #ffffff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.35);
transition: transform 120ms ease;
}
.toggleControl input:checked + .toggleTrack { background: #2563eb; }
.toggleControl input:checked + .toggleTrack::after { transform: translateX(12px); }
.dangerCommand {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 34px;
padding: 6px 10px;
border-color: #fecaca;
background: #fff7f7;
color: #b4232c;
}
.dangerCommand .commandIcon { color: #cf222e; }
.dangerCommand:hover:not(:disabled) { border-color: #e5484d; background: #fff0f0; }
.commandRibbon :is(button, select, input):focus-visible,
.toggleControl input:focus-visible + .toggleTrack {
outline: 2px solid var(--ribbon-focus);
outline-offset: 2px;
}
.srOnly {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* ── Editor info bar (below project toolbar) ─────────────────────── */ /* ── Editor info bar (below project toolbar) ─────────────────────── */
.editorBar { .editorBar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
height: 36px; height: 30px;
padding: 0 16px; padding: 0 16px;
background: #f7f8fa; background: #f7f8fa;
border-bottom: 1px solid #e5e7eb; border-bottom: 1px solid #e5e7eb;
@ -56,9 +329,10 @@
} }
.pageEventEditor { .pageEventEditor {
display: flex; display: grid;
grid-template-columns: auto 142px;
align-items: center; align-items: center;
gap: 6px; gap: 5px 7px;
min-width: 0; min-width: 0;
} }
@ -79,14 +353,25 @@
} }
.pageEventSelect { .pageEventSelect {
width: 190px; width: 142px;
min-width: 0; min-width: 0;
padding: 2px 6px; padding: 5px 7px;
border: 1px solid #d0d7de; }
border-radius: 4px;
background: #ffffff; @media (max-width: 1180px) {
color: #1f2328; .ribbonGroup { padding-inline: 9px; }
font-size: 11px; .settingsGroup { min-width: 520px; }
}
@media (max-width: 780px) {
.commandRibbon { padding-inline: 7px; }
.ribbonGroupLabel code { display: none; }
.commandButton span:last-child,
.dangerCommand span:last-child { display: none; }
.commandButton,
.dangerCommand { width: 34px; padding-inline: 5px; }
.pagesGroup { min-width: 320px; }
.settingsGroup { min-width: 500px; }
} }
/* ── Body: palette | canvas | info ───────────────────────────────── */ /* ── Body: palette | canvas | info ───────────────────────────────── */

View File

@ -6,7 +6,6 @@ import { useProject } from '../../context/ProjectContext';
import styles from './VisualEditor.module.css'; import styles from './VisualEditor.module.css';
import type { ComponentStyle, ComponentType, DropdownOption, TableColumn, TableRow } from '../../types/project'; import type { ComponentStyle, ComponentType, DropdownOption, TableColumn, TableRow } from '../../types/project';
import ButtonEventEditor from './ButtonEventEditor'; import ButtonEventEditor from './ButtonEventEditor';
import PageEventEditor from './PageEventEditor';
import ComponentDeleteDialog from './ComponentDeleteDialog'; import ComponentDeleteDialog from './ComponentDeleteDialog';
import { findComponentReferences, type PendingComponentDeletion } from './componentDeletionUtils'; import { findComponentReferences, type PendingComponentDeletion } from './componentDeletionUtils';
import ValidationSummary from '../ValidationSummary'; import ValidationSummary from '../ValidationSummary';
@ -492,19 +491,14 @@ function VisualEditor(): React.ReactElement {
return ( return (
<div className={styles.editor}> <div className={styles.editor}>
{/* ── Project toolbar (New / Save / Load) ───────────────────── */} {/* ── Project toolbar (New / Save / Load) ───────────────────── */}
<ProjectToolbar /> <ProjectToolbar variant="ribbon" />
<PageManager /> <PageManager events={activePage.events} actions={doc.project.actions} onEventsChange={updatePageEvents} />
<ValidationSummary /> <ValidationSummary />
{/* ── Editor toolbar (page info) ────────────────────────────── */} {/* ── Editor toolbar (page info) ────────────────────────────── */}
<div className={styles.editorBar}> <div className={styles.editorBar}>
<span className={styles.projectName}>{doc.project.name}</span> <span className={styles.projectName}>{doc.project.name}</span>
<span className={styles.pageName}>{activePage.name}</span> <span className={styles.pageName}>{activePage.name}</span>
<PageEventEditor
events={activePage.events}
actions={doc.project.actions}
onChange={updatePageEvents}
/>
<span className={styles.componentCount}> <span className={styles.componentCount}>
{activePage.components.length} component {activePage.components.length} component
{activePage.components.length !== 1 ? 's' : ''} {activePage.components.length !== 1 ? 's' : ''}