diff --git a/Source/JavaScript/engine/aggregateContributions.ts b/Source/JavaScript/engine/aggregateContributions.ts
new file mode 100644
index 0000000..15c340e
--- /dev/null
+++ b/Source/JavaScript/engine/aggregateContributions.ts
@@ -0,0 +1,40 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import { Contribution } from '@cratis/scene.model';
+
+/**
+ * Aggregates every {@link Contribution} targeting a given contribution point, in render order - part of
+ * Cratis/Scene#2. A widget bound to a contribution point is never wired to a fixed source: call this
+ * again whenever the underlying contribution set changes and it recomputes, the same way a projection
+ * recomputes a read model from events.
+ *
+ * Resolving *which* contribution point a `contribute to` declaration targets (nearest enclosing, or an
+ * explicit override) already happened before a {@link Contribution} exists in this model - see its own
+ * doc comment - so there is no ambiguity left to resolve here, only ordering.
+ *
+ * @param contributions Every contribution in scope, targeting any contribution point.
+ * @param contributionPointName The name of the contribution point to aggregate.
+ * @returns The matching contributions, ordered by `order` ascending; contributions with no `order` sort after every ordered one, in their original relative order.
+ */
+export function aggregateContributions(contributions: Contribution[], contributionPointName: string): Contribution[] {
+ return contributions
+ .map((contribution, index) => ({ contribution, index }))
+ .filter(({ contribution }) => contribution.contributionPointName === contributionPointName)
+ .sort((a, b) => {
+ if (a.contribution.order === undefined && b.contribution.order === undefined) {
+ return a.index - b.index;
+ }
+
+ if (a.contribution.order === undefined) {
+ return 1;
+ }
+
+ if (b.contribution.order === undefined) {
+ return -1;
+ }
+
+ return a.contribution.order - b.contribution.order;
+ })
+ .map(({ contribution }) => contribution);
+}
diff --git a/Source/JavaScript/engine/for_aggregateContributions/when_aggregating_by_contribution_point.ts b/Source/JavaScript/engine/for_aggregateContributions/when_aggregating_by_contribution_point.ts
new file mode 100644
index 0000000..da9fbad
--- /dev/null
+++ b/Source/JavaScript/engine/for_aggregateContributions/when_aggregating_by_contribution_point.ts
@@ -0,0 +1,31 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import { Contribution } from '@cratis/scene.model';
+import { aggregateContributions } from '../index';
+
+function contribution(contributionPointName: string, id: string, order?: number): Contribution {
+ return { contributionPointName, content: { id, properties: {} }, order };
+}
+
+describe('when aggregating by contribution point', () => {
+ it('should exclude contributions targeting a different contribution point', () => {
+ const result = aggregateContributions([contribution('Navigation', 'a'), contribution('Toolbar', 'b')], 'Navigation');
+ result.map(item => item.content.id).should.deep.equal(['a']);
+ });
+
+ it('should sort ordered contributions ascending by order', () => {
+ const result = aggregateContributions([contribution('Navigation', 'second', 20), contribution('Navigation', 'first', 10)], 'Navigation');
+ result.map(item => item.content.id).should.deep.equal(['first', 'second']);
+ });
+
+ it('should sort unordered contributions after every ordered one', () => {
+ const result = aggregateContributions([contribution('Navigation', 'unordered'), contribution('Navigation', 'ordered', 10)], 'Navigation');
+ result.map(item => item.content.id).should.deep.equal(['ordered', 'unordered']);
+ });
+
+ it('should preserve original relative order among unordered contributions', () => {
+ const result = aggregateContributions([contribution('Navigation', 'first'), contribution('Navigation', 'second')], 'Navigation');
+ result.map(item => item.content.id).should.deep.equal(['first', 'second']);
+ });
+});
diff --git a/Source/JavaScript/engine/index.ts b/Source/JavaScript/engine/index.ts
index b6638e3..a650624 100644
--- a/Source/JavaScript/engine/index.ts
+++ b/Source/JavaScript/engine/index.ts
@@ -10,3 +10,4 @@ export * from './resolveComponentName';
export * from './computeSizeClass';
export * from './evaluateFlowArrangement';
export * from './evaluateFreeformArrangement';
+export * from './aggregateContributions';
diff --git a/Source/JavaScript/react/NavBar/NavBar.tsx b/Source/JavaScript/react/NavBar/NavBar.tsx
new file mode 100644
index 0000000..5e23c17
--- /dev/null
+++ b/Source/JavaScript/react/NavBar/NavBar.tsx
@@ -0,0 +1,53 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import { Contribution, NavigationItem } from '@cratis/scene.model';
+import { aggregateContributions } from '@cratis/scene.engine';
+import { extractNavigationItem } from './extractNavigationItem';
+
+export interface NavBarProps {
+ /** Every contribution in scope - `NavBar` aggregates the ones targeting `Navigation` itself. */
+ contributions: Contribution[];
+
+ /**
+ * Turns a {@link NavigationItem}'s target screen and route parameters into a concrete route - a URL
+ * path, a query string, or a native deep link. Owning this as a caller-supplied function, rather than
+ * a fixed URL scheme, is what makes `NavBar` usable unmodified across renderers (Cratis/Scene#2).
+ */
+ renderRoute: (item: NavigationItem) => string;
+}
+
+/**
+ * Renders the aggregated `Navigation` contributions from across the current element tree - part of
+ * Cratis/Scene#2. `NavBar` is not wired to a fixed source: pass it whatever contributions are currently
+ * in scope and it recomputes, the same way any contribution-point consumer does.
+ */
+export function NavBar({ contributions, renderRoute }: NavBarProps) {
+ const items = aggregateContributions(contributions, 'Navigation')
+ .map(extractNavigationItem)
+ .filter((item): item is NavigationItem => item !== undefined);
+
+ const hasGroups = items.some(item => item.group !== undefined);
+ if (!hasGroups) {
+ return (
+
+ );
+ }
+
+ const groupNames = [...new Set(items.map(item => item.group ?? ''))];
+ return (
+
+ );
+}
+
+function NavBarItem({ item, href }: { item: NavigationItem; href: string }) {
+ return {item.label};
+}
diff --git a/Source/JavaScript/react/NavBar/extractNavigationItem.ts b/Source/JavaScript/react/NavBar/extractNavigationItem.ts
new file mode 100644
index 0000000..40a1397
--- /dev/null
+++ b/Source/JavaScript/react/NavBar/extractNavigationItem.ts
@@ -0,0 +1,40 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import { BindingExpression, Contribution, ExternalComponent, NavigationItem } from '@cratis/scene.model';
+
+/**
+ * Reads a {@link NavigationItem} out of a {@link Contribution} to the built-in `Navigation` contribution
+ * point. `Contribution.content` carries an {@link ExternalComponent} whose open `properties` bag holds the
+ * navigation-specific values (`label`, `targetScreen`, `routeParameterBindings`, `order`, `group`) - the
+ * same names as {@link NavigationItem}'s own fields. This property-bag contract is Scene#2's own choice,
+ * made without a real Screenplay-to-Scene translation to confirm it against (Stage#37 hasn't been built
+ * yet) - whoever builds that seam should either produce contributions matching this contract or this
+ * function should move to match whatever Stage#37 actually emits.
+ *
+ * @param contribution The contribution to read.
+ * @returns The extracted {@link NavigationItem}, or `undefined` when `contribution.content` isn't an {@link ExternalComponent} or is missing a required property.
+ */
+export function extractNavigationItem(contribution: Contribution): NavigationItem | undefined {
+ const content = contribution.content as Partial;
+ if (!content.properties) {
+ return undefined;
+ }
+
+ const { label, targetScreen, routeParameterBindings, order, group } = content.properties as Record;
+ if (typeof label !== 'string' || typeof targetScreen !== 'string') {
+ return undefined;
+ }
+
+ return {
+ label,
+ targetScreen,
+ routeParameterBindings: isBindingExpressionRecord(routeParameterBindings) ? routeParameterBindings : {},
+ order: typeof order === 'number' ? order : contribution.order,
+ group: typeof group === 'string' ? group : undefined,
+ };
+}
+
+function isBindingExpressionRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
diff --git a/Source/JavaScript/react/NavBar/for_NavBar/when_rendering_navigation_contributions.tsx b/Source/JavaScript/react/NavBar/for_NavBar/when_rendering_navigation_contributions.tsx
new file mode 100644
index 0000000..e5e8bae
--- /dev/null
+++ b/Source/JavaScript/react/NavBar/for_NavBar/when_rendering_navigation_contributions.tsx
@@ -0,0 +1,62 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import { render, screen } from '@testing-library/react';
+import { Contribution, ExternalComponent, HorizontalAlignment, VerticalAlignment, Visibility } from '@cratis/scene.model';
+import { NavBar } from '../NavBar';
+
+function navigationContribution(id: string, properties: Record, order?: number): Contribution {
+ const content: ExternalComponent = {
+ id,
+ name: id,
+ properties,
+ visibility: Visibility.Visible,
+ isEnabled: true,
+ opacity: 1,
+ size: {},
+ zIndex: 0,
+ minimumSize: {},
+ maximumSize: {},
+ margin: { left: 0, top: 0, right: 0, bottom: 0 },
+ horizontalAlignment: HorizontalAlignment.Stretch,
+ verticalAlignment: VerticalAlignment.Stretch,
+ componentName: 'core:navigation-item',
+ slots: {},
+ };
+ return { contributionPointName: 'Navigation', content, order };
+}
+
+describe('when rendering navigation contributions', () => {
+ describe('and no item declares a group', () => {
+ const contributions = [
+ navigationContribution('adjustments', { label: 'Adjustments', targetScreen: 'Adjustments' }, 20),
+ navigationContribution('invoices', { label: 'Invoices', targetScreen: 'InvoiceList' }, 10),
+ ];
+
+ beforeEach(() => {
+ render( `/${item.targetScreen}`} />);
+ });
+
+ it('should render every item as a link to its rendered route', () => {
+ screen.getByRole('link', { name: 'Invoices' }).getAttribute('href')!.should.equal('/InvoiceList');
+ screen.getByRole('link', { name: 'Adjustments' }).getAttribute('href')!.should.equal('/Adjustments');
+ });
+
+ it('should render items in aggregated order', () => {
+ const links = screen.getAllByRole('link').map(link => link.textContent);
+ links.should.deep.equal(['Invoices', 'Adjustments']);
+ });
+ });
+
+ describe('and a contribution cannot be extracted into a navigation item', () => {
+ const contributions = [navigationContribution('broken', { label: 'Broken' })];
+
+ beforeEach(() => {
+ render( `/${item.targetScreen}`} />);
+ });
+
+ it('should skip it rather than render a broken link', () => {
+ screen.queryAllByRole('link').should.have.lengthOf(0);
+ });
+ });
+});
diff --git a/Source/JavaScript/react/NavBar/for_extractNavigationItem/when_extracting_from_a_navigation_contribution.ts b/Source/JavaScript/react/NavBar/for_extractNavigationItem/when_extracting_from_a_navigation_contribution.ts
new file mode 100644
index 0000000..f4642f9
--- /dev/null
+++ b/Source/JavaScript/react/NavBar/for_extractNavigationItem/when_extracting_from_a_navigation_contribution.ts
@@ -0,0 +1,53 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import { Contribution, ExternalComponent, HorizontalAlignment, NavigationItem, VerticalAlignment, Visibility } from '@cratis/scene.model';
+import { extractNavigationItem } from '../extractNavigationItem';
+
+function navigationContribution(properties: Record, order?: number): Contribution {
+ const content: ExternalComponent = {
+ id: 'nav-item',
+ name: 'nav-item',
+ properties,
+ visibility: Visibility.Visible,
+ isEnabled: true,
+ opacity: 1,
+ size: {},
+ zIndex: 0,
+ minimumSize: {},
+ maximumSize: {},
+ margin: { left: 0, top: 0, right: 0, bottom: 0 },
+ horizontalAlignment: HorizontalAlignment.Stretch,
+ verticalAlignment: VerticalAlignment.Stretch,
+ componentName: 'core:navigation-item',
+ slots: {},
+ };
+ return { contributionPointName: 'Navigation', content, order };
+}
+
+describe('when extracting from a navigation contribution', () => {
+ describe('and the required properties are present', () => {
+ let result: NavigationItem | undefined;
+
+ beforeEach(() => {
+ result = extractNavigationItem(navigationContribution({ label: 'Invoices', targetScreen: 'InvoiceList', group: 'Sales' }, 10));
+ });
+
+ it('should extract the label', () => result!.label.should.equal('Invoices'));
+ it('should extract the target screen', () => result!.targetScreen.should.equal('InvoiceList'));
+ it('should extract the group', () => result!.group!.should.equal('Sales'));
+ it('should fall back to the contribution order when the properties bag has none', () => result!.order!.should.equal(10));
+ });
+
+ describe('and the label is missing', () => {
+ it('should return undefined', () => {
+ (extractNavigationItem(navigationContribution({ targetScreen: 'InvoiceList' })) === undefined).should.be.true;
+ });
+ });
+
+ describe('and the target screen is missing', () => {
+ it('should return undefined', () => {
+ (extractNavigationItem(navigationContribution({ label: 'Invoices' })) === undefined).should.be.true;
+ });
+ });
+});
diff --git a/Source/JavaScript/react/NavBar/index.ts b/Source/JavaScript/react/NavBar/index.ts
new file mode 100644
index 0000000..877e1ef
--- /dev/null
+++ b/Source/JavaScript/react/NavBar/index.ts
@@ -0,0 +1,5 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+export * from './NavBar';
+export * from './extractNavigationItem';
diff --git a/Source/JavaScript/react/index.ts b/Source/JavaScript/react/index.ts
index 899f547..33e681d 100644
--- a/Source/JavaScript/react/index.ts
+++ b/Source/JavaScript/react/index.ts
@@ -4,3 +4,4 @@
export * from './SceneElementView';
export * from './renderer';
export * from './core';
+export * from './NavBar';