diff --git a/ROADMAP.md b/ROADMAP.md index 881dd79..de3fc6e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 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 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 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 -- [ ] Replace dense native-looking Visual Editor command rows with a grouped professional ribbon. -- [ ] 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. -- [ ] Complete automated regression and accessibility checks plus manual visual acceptance and explicit sign-off. +- [x] Replace dense native-looking Visual Editor command rows with a grouped professional ribbon. +- [x] Distinguish project, page, page-settings, lifecycle, primary, disabled, and destructive controls. +- [x] Preserve Slice 7c behavior while improving desktop responsiveness, keyboard access, focus, and visual hierarchy. +- [x] Complete automated regression and accessibility checks plus manual visual acceptance and explicit sign-off. ## 8. Documentation, Packaging, and Release diff --git a/docs/MVP_TRACEABILITY.md b/docs/MVP_TRACEABILITY.md index 006da8f..f4b1d4f 100644 --- a/docs/MVP_TRACEABILITY.md +++ b/docs/MVP_TRACEABILITY.md @@ -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 | | 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 | -| 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 diff --git a/frontend/src/components/ProjectToolbar/ProjectToolbar.module.css b/frontend/src/components/ProjectToolbar/ProjectToolbar.module.css index e6caef4..a31ac9e 100644 --- a/frontend/src/components/ProjectToolbar/ProjectToolbar.module.css +++ b/frontend/src/components/ProjectToolbar/ProjectToolbar.module.css @@ -11,6 +11,15 @@ 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 { display: flex; align-items: center; @@ -40,6 +49,15 @@ .nameButton:hover { 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 { @@ -77,16 +95,27 @@ flex-shrink: 0; } +.toolbarRibbon .actions { + padding-left: 14px; + border-left: 1px solid #3b4652; +} + .btn { - padding: 5px 12px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 32px; + padding: 6px 13px; background: #21262d; border: 1px solid #30363d; - border-radius: 4px; + border-radius: 6px; font-size: 12px; font-weight: 500; color: #c9d1d9; 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; } @@ -95,14 +124,22 @@ border-color: #8b949e; } +.btn:active:not(:disabled) { transform: translateY(1px); } + +.btnIcon { + font-size: 14px; + line-height: 1; + opacity: 0.9; +} + .btn:disabled { opacity: 0.5; cursor: not-allowed; } .btnPrimary { - background: #1f6feb; - border-color: #1f6feb; + background: linear-gradient(180deg, #2f81f7 0%, #1f6feb 100%); + border-color: #438ef7; color: #ffffff; } diff --git a/frontend/src/components/ProjectToolbar/ProjectToolbar.tsx b/frontend/src/components/ProjectToolbar/ProjectToolbar.tsx index bd23f6e..fae3613 100644 --- a/frontend/src/components/ProjectToolbar/ProjectToolbar.tsx +++ b/frontend/src/components/ProjectToolbar/ProjectToolbar.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'; import { useProject } from '../../context/ProjectContext'; import styles from './ProjectToolbar.module.css'; -function ProjectToolbar(): React.ReactElement { +function ProjectToolbar({ variant = 'default' }: { variant?: 'default' | 'ribbon' }): React.ReactElement { const { projectRowId, projectName, @@ -61,7 +61,7 @@ function ProjectToolbar(): React.ReactElement { return ( <> {/* ── Toolbar strip ─────────────────────────────────────────── */} -
+
{/* Project name — click to rename */}
{editingName ? ( @@ -97,7 +97,7 @@ function ProjectToolbar(): React.ReactElement { disabled={isLoading || isSaving} title="Create a new project (current unsaved changes will be lost)" > - New + New {/* Save */} @@ -107,7 +107,7 @@ function ProjectToolbar(): React.ReactElement { disabled={isSaving || isLoading} title={projectRowId === null ? 'Save project to backend' : 'Update project on backend'} > - {isSaving ? 'Saving…' : (projectRowId === null ? 'Save' : 'Update')} + {isSaving ? 'Saving…' : (projectRowId === null ? 'Save' : 'Update')} {/* Load */} @@ -117,7 +117,7 @@ function ProjectToolbar(): React.ReactElement { disabled={isLoading || isSaving} title="Load an existing project from the backend" > - {isLoading ? 'Loading…' : 'Load'} + {isLoading ? 'Loading…' : 'Load'}
diff --git a/frontend/src/components/VisualEditor/PageEventEditor.test.tsx b/frontend/src/components/VisualEditor/PageEventEditor.test.tsx index 63955cb..53dfec5 100644 --- a/frontend/src/components/VisualEditor/PageEventEditor.test.tsx +++ b/frontend/src/components/VisualEditor/PageEventEditor.test.tsx @@ -40,4 +40,19 @@ describe('PageEventEditor', () => { }); expect(onChange).toHaveBeenCalledWith([]); }); + + test('configures a distinct onEnter lifecycle action', () => { + const onChange = jest.fn(); + act(() => root.render()); + const select = container.querySelector('#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' }, + ]); + }); }); diff --git a/frontend/src/components/VisualEditor/PageEventEditor.tsx b/frontend/src/components/VisualEditor/PageEventEditor.tsx index a0c42cf..61d57c3 100644 --- a/frontend/src/components/VisualEditor/PageEventEditor.tsx +++ b/frontend/src/components/VisualEditor/PageEventEditor.tsx @@ -24,7 +24,7 @@ export default function PageEventEditor({ events, actions, onChange }: Props): R {actions.map((action) => )} - setEnter(event.target.value)}> {actions.map(action => )} diff --git a/frontend/src/components/VisualEditor/PageManager.test.tsx b/frontend/src/components/VisualEditor/PageManager.test.tsx new file mode 100644 index 0000000..ab61b20 --- /dev/null +++ b/frontend/src/components/VisualEditor/PageManager.test.tsx @@ -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; + +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); + }); + + afterEach(() => { act(() => root.unmount()); container.remove(); }); + + test('presents clearly named command groups and page state', () => { + act(() => root.render()); + 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('[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('[aria-label="Move page left"]')?.disabled).toBe(true); + expect(container.querySelector('[aria-label="Move page right"]')?.disabled).toBe(false); + }); + + test('wires page commands and fields to existing canonical operations', () => { + act(() => root.render()); + 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('[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('[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' }); + }); +}); diff --git a/frontend/src/components/VisualEditor/PageManager.tsx b/frontend/src/components/VisualEditor/PageManager.tsx index e5b37f3..59fc188 100644 --- a/frontend/src/components/VisualEditor/PageManager.tsx +++ b/frontend/src/components/VisualEditor/PageManager.tsx @@ -1,8 +1,83 @@ import React from 'react'; 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{ - 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)); - return
Pages
{pages.filter(page=>page.id===activePageId).map(page=>
{page.id}
)}
; +type Props = { + events: ComponentEvent[] | undefined; + actions: RestAction[]; + onEventsChange: (events: ComponentEvent[]) => void; +}; + +function CommandIcon({ children }: { children: React.ReactNode }): React.ReactElement { + return ; +} + +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 ( +
+
+
+
+ + + +
+ + +
+
+
Pages
+
+ +
+
+ + +
+ + +
+
+
Page settings {activePage.id}
+
+ +
+
+
Lifecycle
+
+ +
+
+ +
+
Page actions
+
+
+
+ ); } diff --git a/frontend/src/components/VisualEditor/VisualEditor.module.css b/frontend/src/components/VisualEditor/VisualEditor.module.css index 004dd92..fbcd64a 100644 --- a/frontend/src/components/VisualEditor/VisualEditor.module.css +++ b/frontend/src/components/VisualEditor/VisualEditor.module.css @@ -22,13 +22,286 @@ 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) ─────────────────────── */ .editorBar { display: flex; align-items: center; gap: 12px; - height: 36px; + height: 30px; padding: 0 16px; background: #f7f8fa; border-bottom: 1px solid #e5e7eb; @@ -56,9 +329,10 @@ } .pageEventEditor { - display: flex; + display: grid; + grid-template-columns: auto 142px; align-items: center; - gap: 6px; + gap: 5px 7px; min-width: 0; } @@ -79,14 +353,25 @@ } .pageEventSelect { - width: 190px; + width: 142px; min-width: 0; - padding: 2px 6px; - border: 1px solid #d0d7de; - border-radius: 4px; - background: #ffffff; - color: #1f2328; - font-size: 11px; + padding: 5px 7px; +} + +@media (max-width: 1180px) { + .ribbonGroup { padding-inline: 9px; } + .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 ───────────────────────────────── */ diff --git a/frontend/src/components/VisualEditor/VisualEditor.tsx b/frontend/src/components/VisualEditor/VisualEditor.tsx index 1d7114a..0d21496 100644 --- a/frontend/src/components/VisualEditor/VisualEditor.tsx +++ b/frontend/src/components/VisualEditor/VisualEditor.tsx @@ -6,7 +6,6 @@ import { useProject } from '../../context/ProjectContext'; import styles from './VisualEditor.module.css'; import type { ComponentStyle, ComponentType, DropdownOption, TableColumn, TableRow } from '../../types/project'; import ButtonEventEditor from './ButtonEventEditor'; -import PageEventEditor from './PageEventEditor'; import ComponentDeleteDialog from './ComponentDeleteDialog'; import { findComponentReferences, type PendingComponentDeletion } from './componentDeletionUtils'; import ValidationSummary from '../ValidationSummary'; @@ -492,19 +491,14 @@ function VisualEditor(): React.ReactElement { return (
{/* ── Project toolbar (New / Save / Load) ───────────────────── */} - - + + {/* ── Editor toolbar (page info) ────────────────────────────── */}
{doc.project.name} {activePage.name} - {activePage.components.length} component {activePage.components.length !== 1 ? 's' : ''}