+
+
+
Streaming JSON
+
{{ simulator.rawJson() }}|
+
+ {{ simulator.playing() ? 'Streaming...' : simulator.position() >= simulator.total() ? 'Complete' : 'Paused' }}
+ {{ percent() }}%
+
-
-
-
State Controls
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Edit values to update the state store. Rendered elements with $state bindings react.
-
+
+
+
State Controls
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Edit values to update the state store. Rendered elements with $state bindings react.
+
-
-
+
+
`,
})
export class StateManagementComponent implements OnDestroy {
diff --git a/libs/example-layouts/ng-package.json b/libs/example-layouts/ng-package.json
new file mode 100644
index 000000000..e5d0c459b
--- /dev/null
+++ b/libs/example-layouts/ng-package.json
@@ -0,0 +1,7 @@
+{
+ "$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
+ "dest": "../../dist/libs/example-layouts",
+ "lib": {
+ "entryFile": "src/public-api.ts"
+ }
+}
diff --git a/libs/example-layouts/package.json b/libs/example-layouts/package.json
new file mode 100644
index 000000000..a44559346
--- /dev/null
+++ b/libs/example-layouts/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "@cacheplane/example-layouts",
+ "version": "0.0.1",
+ "peerDependencies": {
+ "@angular/core": "^20.0.0 || ^21.0.0",
+ "@angular/common": "^20.0.0 || ^21.0.0"
+ },
+ "license": "PolyForm-Noncommercial-1.0.0",
+ "sideEffects": false
+}
diff --git a/libs/example-layouts/project.json b/libs/example-layouts/project.json
new file mode 100644
index 000000000..8c994fcf5
--- /dev/null
+++ b/libs/example-layouts/project.json
@@ -0,0 +1,34 @@
+{
+ "name": "example-layouts",
+ "$schema": "../../node_modules/nx/schemas/project-schema.json",
+ "sourceRoot": "libs/example-layouts/src",
+ "prefix": "example",
+ "projectType": "library",
+ "tags": [],
+ "targets": {
+ "build": {
+ "executor": "@nx/angular:package",
+ "outputs": ["{workspaceRoot}/dist/{projectRoot}"],
+ "options": {
+ "project": "libs/example-layouts/ng-package.json",
+ "tsConfig": "libs/example-layouts/tsconfig.lib.json"
+ },
+ "configurations": {
+ "production": {
+ "tsConfig": "libs/example-layouts/tsconfig.lib.prod.json"
+ },
+ "development": {}
+ },
+ "defaultConfiguration": "production"
+ },
+ "lint": {
+ "executor": "@nx/eslint:lint"
+ },
+ "test": {
+ "executor": "@nx/vite:test",
+ "options": {
+ "configFile": "libs/example-layouts/vite.config.mts"
+ }
+ }
+ }
+}
diff --git a/libs/example-layouts/src/lib/example-chat-layout.component.spec.ts b/libs/example-layouts/src/lib/example-chat-layout.component.spec.ts
new file mode 100644
index 000000000..2c774b3d3
--- /dev/null
+++ b/libs/example-layouts/src/lib/example-chat-layout.component.spec.ts
@@ -0,0 +1,159 @@
+import { Component } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import { ExampleChatLayoutComponent } from './example-chat-layout.component';
+
+@Component({
+ standalone: true,
+ imports: [ExampleChatLayoutComponent],
+ template: `
+
+ Main Content
+ Sidebar Content
+
+ `,
+})
+class TestHostComponent {}
+
+@Component({
+ standalone: true,
+ imports: [ExampleChatLayoutComponent],
+ template: `
+
+ Main Only
+
+ `,
+})
+class NoSidebarHostComponent {}
+
+@Component({
+ standalone: true,
+ imports: [ExampleChatLayoutComponent],
+ template: `
+
+ Main
+ Sidebar
+
+ `,
+})
+class LeftSidebarHostComponent {}
+
+@Component({
+ standalone: true,
+ imports: [ExampleChatLayoutComponent],
+ template: `
+
+ Main
+ Sidebar
+
+ `,
+})
+class CustomWidthHostComponent {}
+
+describe('ExampleChatLayoutComponent', () => {
+ it('should render main and sidebar content', async () => {
+ await TestBed.configureTestingModule({
+ imports: [TestHostComponent],
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(TestHostComponent);
+ fixture.detectChanges();
+
+ const el = fixture.nativeElement as HTMLElement;
+ expect(el.querySelector('[data-testid="main-content"]')?.textContent).toBe('Main Content');
+ expect(el.querySelector('[data-testid="sidebar-content"]')?.textContent).toBe('Sidebar Content');
+ });
+
+ it('should use flex-col md:flex-row for responsive layout', async () => {
+ await TestBed.configureTestingModule({
+ imports: [TestHostComponent],
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(TestHostComponent);
+ fixture.detectChanges();
+
+ const el = fixture.nativeElement as HTMLElement;
+ const flexContainer = el.querySelector('.flex.flex-col');
+ expect(flexContainer).toBeTruthy();
+ expect(flexContainer?.classList.contains('md:flex-row')).toBe(true);
+ });
+
+ it('should hide aside when no sidebar content is projected', async () => {
+ await TestBed.configureTestingModule({
+ imports: [NoSidebarHostComponent],
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(NoSidebarHostComponent);
+ fixture.detectChanges();
+
+ const el = fixture.nativeElement as HTMLElement;
+ expect(el.querySelector('[data-testid="main-only"]')?.textContent).toBe('Main Only');
+ const aside = el.querySelector('aside');
+ expect(aside).toBeTruthy();
+ expect(aside?.children.length).toBe(0);
+ });
+
+ it('should apply md:flex-row-reverse for left sidebar position', async () => {
+ await TestBed.configureTestingModule({
+ imports: [LeftSidebarHostComponent],
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(LeftSidebarHostComponent);
+ fixture.detectChanges();
+
+ const el = fixture.nativeElement as HTMLElement;
+ const flexContainer = el.querySelector('.flex.flex-col');
+ expect(flexContainer?.classList.contains('md:flex-row-reverse')).toBe(true);
+ });
+
+ it('should use border-r instead of border-l for left sidebar', async () => {
+ await TestBed.configureTestingModule({
+ imports: [LeftSidebarHostComponent],
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(LeftSidebarHostComponent);
+ fixture.detectChanges();
+
+ const el = fixture.nativeElement as HTMLElement;
+ const aside = el.querySelector('aside');
+ expect(aside?.classList.contains('md:border-r')).toBe(true);
+ expect(aside?.classList.contains('md:border-l')).toBe(false);
+ });
+
+ it('should apply custom sidebar width class', async () => {
+ await TestBed.configureTestingModule({
+ imports: [CustomWidthHostComponent],
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(CustomWidthHostComponent);
+ fixture.detectChanges();
+
+ const el = fixture.nativeElement as HTMLElement;
+ const aside = el.querySelector('aside');
+ expect(aside?.classList.contains('md:w-80')).toBe(true);
+ });
+
+ it('should apply default w-72 sidebar width', async () => {
+ await TestBed.configureTestingModule({
+ imports: [TestHostComponent],
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(TestHostComponent);
+ fixture.detectChanges();
+
+ const el = fixture.nativeElement as HTMLElement;
+ const aside = el.querySelector('aside');
+ expect(aside?.classList.contains('md:w-72')).toBe(true);
+ });
+
+ it('should set host to full viewport height', async () => {
+ await TestBed.configureTestingModule({
+ imports: [TestHostComponent],
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(TestHostComponent);
+ fixture.detectChanges();
+
+ const hostEl = fixture.nativeElement.querySelector('example-chat-layout') as HTMLElement;
+ expect(hostEl).toBeTruthy();
+ });
+});
diff --git a/libs/example-layouts/src/lib/example-chat-layout.component.ts b/libs/example-layouts/src/lib/example-chat-layout.component.ts
new file mode 100644
index 000000000..b94aa62be
--- /dev/null
+++ b/libs/example-layouts/src/lib/example-chat-layout.component.ts
@@ -0,0 +1,54 @@
+import { Component, computed, input } from '@angular/core';
+
+/**
+ * Responsive chat-style layout with a main content area and optional sidebar.
+ *
+ * Content projection slots:
+ * - `[main]` — primary content area (required)
+ * - `[sidebar]` — optional sidebar that stacks below on mobile, beside on desktop
+ *
+ * When no `[sidebar]` content is projected, the `