- | {row.displayName} |
+
+
+ {row.displayName.trim().slice(0, 1).toLocaleUpperCase(locale)}
+
+ {row.displayName}
+ |
{PRESET_LABELS[locale][preset]} |
{AGENT_GRANT_LABELS[locale][row.agentGrant ?? 'NONE']}
diff --git a/apps/web/src/features/settings/session-list.tsx b/apps/web/src/features/settings/session-list.tsx
index b1288ca7..6856dc43 100644
--- a/apps/web/src/features/settings/session-list.tsx
+++ b/apps/web/src/features/settings/session-list.tsx
@@ -7,17 +7,29 @@ export type SessionRow = {
export type SessionListProperties = {
readonly locale: 'vi-VN' | 'en';
readonly sessions: readonly SessionRow[];
- readonly onRevoke: (sessionId: string) => void;
+ readonly onRevoke?: (sessionId: string) => void;
};
export function SessionList({ locale, sessions, onRevoke }: SessionListProperties) {
const label = locale === 'vi-VN' ? 'Danh sách phiên đăng nhập' : 'Session list';
return (
-
+
{sessions.map((session) => (
-
- {session.deviceLabel}
- {session.current ? null : (
+
+
+ {session.deviceLabel}
+
+ {session.current
+ ? locale === 'vi-VN'
+ ? 'Đang hoạt động · Được bảo vệ'
+ : 'Active now · Protected'
+ : locale === 'vi-VN'
+ ? 'Phiên khác'
+ : 'Other session'}
+
+
+ {session.current || onRevoke === undefined ? null : (
diff --git a/apps/web/src/features/settings/workspace-settings-page.tsx b/apps/web/src/features/settings/workspace-settings-page.tsx
index ee69e082..8fd103ae 100644
--- a/apps/web/src/features/settings/workspace-settings-page.tsx
+++ b/apps/web/src/features/settings/workspace-settings-page.tsx
@@ -1,5 +1,7 @@
import type { SupportedLocaleV1 } from '@databreeze/i18n/v1';
+import { useState } from 'react';
import { appMessage } from '../../app/messages.ts';
+import { dashboardDemoMode } from '../dashboards/dashboard-api.ts';
import {
setAgentGrant,
useWorkspaceSettingsResource,
@@ -8,7 +10,39 @@ import {
type WorkspaceSettingsState,
} from './settings-api.ts';
import { MemberAccessTable, type MemberAccessRow } from './member-access-table.tsx';
-import { SessionList } from './session-list.tsx';
+import { SessionList, type SessionRow } from './session-list.tsx';
+import './workspace-settings.css';
+
+const DEMO_WORKSPACE_ID = '00000000-0000-4000-8000-000000000401';
+const DEMO_MEMBER_ID = '00000000-0000-4000-8000-000000000402';
+
+const DEMO_WORKSPACE_SETTINGS: WorkspaceSettingsProjection = Object.freeze({
+ workspaceId: DEMO_WORKSPACE_ID,
+ canManage: true,
+ members: Object.freeze([
+ Object.freeze({
+ memberId: DEMO_MEMBER_ID,
+ displayName: 'Mai Quỳnh',
+ accessPreset: 'OWNER',
+ agentGrantLevel: 'APPLY_CONFIRMED_CHANGES',
+ agentGrantRevision: 1,
+ membershipRevision: 1,
+ }),
+ ]),
+});
+
+function demoSessions(locale: SupportedLocaleV1): readonly SessionRow[] {
+ return Object.freeze([
+ Object.freeze({
+ sessionId: '00000000-0000-4000-8000-000000000403',
+ deviceLabel:
+ locale === 'vi-VN'
+ ? 'Chrome · Windows · Phiên hiện tại'
+ : 'Chrome · Windows · Current session',
+ current: true,
+ }),
+ ]);
+}
export interface WorkspaceSettingsPageProperties {
readonly locale: SupportedLocaleV1;
@@ -17,6 +51,7 @@ export interface WorkspaceSettingsPageProperties {
readonly projection?: WorkspaceSettingsProjection;
readonly state?: WorkspaceSettingsState;
readonly onRetry?: () => void;
+ readonly sessions?: readonly SessionRow[];
readonly onAgentGrantChange?: (
memberId: string,
level: NonNullable,
@@ -44,6 +79,7 @@ export function WorkspaceSettingsPage({
projection,
state,
onRetry,
+ sessions = [],
onAgentGrantChange,
}: WorkspaceSettingsPageProperties) {
const controlled = canManage !== undefined || projection !== undefined || state !== undefined;
@@ -70,30 +106,103 @@ export function WorkspaceSettingsPage({
aria-label={appMessage(locale, 'settings.workspace.title')}
className="workspace-settings-page"
>
-
{appMessage(locale, 'settings.workspace.title')}
+
{activeState.status === 'loading' ? (
- {appMessage(locale, 'settings.workspace.loading')}
+
+ {appMessage(locale, 'settings.workspace.loading')}
+
) : activeState.status === 'error' ? (
-
- {appMessage(locale, 'settings.workspace.error')}
+
+ {appMessage(locale, 'settings.workspace.error')}
-
+
) : (
<>
{!activeCanManage ? (
- {appMessage(locale, 'settings.workspace.viewerReadOnly')}
+
+ {appMessage(locale, 'settings.workspace.viewerReadOnly')}
+
) : null}
- {appMessage(locale, 'settings.workspace.members')}
-
- {appMessage(locale, 'settings.workspace.sessions')}
- undefined} sessions={[]} />
+
+
+ {locale === 'vi-VN' ? 'Thành viên' : 'Members'}
+ {activeProjection?.members.length ?? 0}
+
+
+ {locale === 'vi-VN' ? 'Phiên đang hoạt động' : 'Active sessions'}
+ {sessions.length}
+
+
+ {locale === 'vi-VN' ? 'Chế độ quản lý' : 'Management mode'}
+
+ {activeCanManage
+ ? locale === 'vi-VN'
+ ? 'Đầy đủ'
+ : 'Full'
+ : locale === 'vi-VN'
+ ? 'Chỉ xem'
+ : 'View only'}
+
+
+
+
+
+
+ {locale === 'vi-VN' ? 'Quyền truy cập' : 'Access control'}
+ {appMessage(locale, 'settings.workspace.members')}
+
+
+ {locale === 'vi-VN'
+ ? 'Thay đổi được kiểm soát theo phiên bản'
+ : 'Revision-controlled changes'}
+
+
+
+
+
+
+
+
+
+ {locale === 'vi-VN' ? 'Bảo mật' : 'Security'}
+ {appMessage(locale, 'settings.workspace.sessions')}
+
+
+
+
>
)}
@@ -101,13 +210,47 @@ export function WorkspaceSettingsPage({
}
/** Web-019: bind the owner-only control to IAM's revisioned agent-grant endpoint. */
-export function WorkspaceSettingsRoutePage({ locale }: { readonly locale: SupportedLocaleV1 }) {
- const live = useWorkspaceSettingsResource(true);
+export function WorkspaceSettingsRoutePage({
+ locale,
+ demoMode = dashboardDemoMode(),
+}: {
+ readonly locale: SupportedLocaleV1;
+ readonly demoMode?: boolean;
+}) {
+ const [demoProjection, setDemoProjection] = useState(DEMO_WORKSPACE_SETTINGS);
+ const live = useWorkspaceSettingsResource(!demoMode);
const projection = live.state.projection;
const baseUrl =
typeof import.meta.env['VITE_DATABREEZE_API_BASE_URL'] === 'string'
? String(import.meta.env['VITE_DATABREEZE_API_BASE_URL']).replace(/\/$/u, '')
: '';
+ if (demoMode) {
+ return (
+ {
+ setDemoProjection((current) =>
+ Object.freeze({
+ ...current,
+ members: Object.freeze(
+ current.members.map((member) =>
+ member.memberId === memberId && member.agentGrantRevision === expectedRevision
+ ? Object.freeze({
+ ...member,
+ agentGrantLevel: level,
+ agentGrantRevision: member.agentGrantRevision + 1,
+ })
+ : member,
+ ),
+ ),
+ }),
+ );
+ }}
+ projection={demoProjection}
+ sessions={demoSessions(locale)}
+ />
+ );
+ }
return (
div {
+ display: grid;
+ gap: 8px;
+ padding: 18px 20px;
+ background: #fff;
+}
+
+.workspace-settings-page__summary span {
+ color: #75839b;
+ font-size: 12px;
+ font-weight: 700;
+}
+
+.workspace-settings-page__summary strong {
+ color: #102a63;
+ font-size: 20px;
+}
+
+.workspace-settings-page__section {
+ margin-top: 18px;
+ padding: 22px;
+ border: 1px solid #dbe5f4;
+ border-radius: 18px;
+ background: #fff;
+ box-shadow: 0 8px 8px rgb(21 54 111 / 4%);
+}
+
+.workspace-settings-page__section-heading {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 20px;
+ margin-bottom: 18px;
+}
+
+.workspace-settings-page__section-heading h2 {
+ margin: 0;
+ font-size: 20px;
+ letter-spacing: -0.025em;
+}
+
+.workspace-settings-page__section-heading > span {
+ color: #78859a;
+ font-size: 12px;
+}
+
+.workspace-settings-page__table-wrap {
+ overflow-x: auto;
+}
+
+.member-access-table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.member-access-table th,
+.member-access-table td {
+ padding: 15px 14px;
+ border-bottom: 1px solid #edf1f7;
+ text-align: start;
+ vertical-align: middle;
+}
+
+.member-access-table th {
+ color: #78859a;
+ background: #f7f9fd;
+ font-size: 11px;
+ font-weight: 800;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+
+.member-access-table th:first-child {
+ border-radius: 10px 0 0 10px;
+}
+
+.member-access-table th:last-child {
+ border-radius: 0 10px 10px 0;
+}
+
+.member-access-table td:first-child {
+ display: flex;
+ align-items: center;
+ gap: 11px;
+}
+
+.member-access-table__avatar {
+ display: grid;
+ width: 36px;
+ height: 36px;
+ flex: 0 0 auto;
+ place-items: center;
+ border-radius: 10px;
+ color: #fff;
+ background: #075de8;
+ font-size: 13px;
+ font-weight: 900;
+}
+
+.member-access-table select {
+ display: block;
+ min-width: 220px;
+ min-height: 38px;
+ margin-top: 7px;
+ padding: 0 34px 0 11px;
+ border: 1px solid #cfdaf0;
+ border-radius: 9px;
+ color: #173264;
+ background: #fff;
+ font: inherit;
+}
+
+.workspace-session-list {
+ display: grid;
+ gap: 10px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.workspace-session-list li {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ align-items: center;
+ gap: 12px;
+ padding: 14px 15px;
+ border: 1px solid #e1e8f3;
+ border-radius: 13px;
+ background: #fbfcff;
+}
+
+.workspace-session-list__device {
+ width: 12px;
+ height: 12px;
+ border: 3px solid #cfe0ff;
+ border-radius: 50%;
+ background: #075de8;
+}
+
+.workspace-session-list strong,
+.workspace-session-list small {
+ display: block;
+}
+
+.workspace-session-list small {
+ margin-top: 3px;
+ color: #78859a;
+}
+
+.workspace-session-list button {
+ min-height: 36px;
+ padding: 0 12px;
+ border: 1px solid #cbd8ee;
+ border-radius: 9px;
+ color: #164080;
+ background: #fff;
+ font-weight: 700;
+}
+
+@media (max-width: 760px) {
+ .workspace-settings-page__hero,
+ .workspace-settings-page__section-heading,
+ .workspace-settings-page__notice--error {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .workspace-settings-page__summary {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css
index aaa1549b..c59e23e3 100644
--- a/apps/web/src/styles.css
+++ b/apps/web/src/styles.css
@@ -265,8 +265,45 @@ a {
margin-block-start: var(--db-spacing-4);
}
.inbox-empty {
- padding-block: var(--db-spacing-8);
+ margin: 0;
+ padding: clamp(32px, 6vw, 72px) 24px;
+ border: 1px dashed #bfd0ec;
+ border-radius: 16px;
color: var(--db-color-text-muted);
+ background: #ffffff;
+ text-align: center;
+}
+.inbox-page {
+ width: min(1180px, 100%);
+ padding-block: clamp(16px, 3vw, 36px) 64px;
+}
+.inbox-page__hero {
+ margin-block-end: 24px;
+ padding-block-end: 20px;
+ border-block-end: 1px solid #dbe5f4;
+}
+.inbox-page__hero h1 {
+ color: #102a63;
+ font-size: clamp(28px, 3vw, 42px);
+ letter-spacing: -0.04em;
+}
+.inbox-page .table-scroll {
+ overflow: auto;
+ border: 1px solid #dbe5f4;
+ border-radius: 16px;
+ background: #ffffff;
+ box-shadow: 0 8px 18px rgb(21 54 111 / 5%);
+}
+.inbox-page table th {
+ color: #6a7890;
+ background: #f6f9ff;
+}
+.inbox-page table td,
+.inbox-page table th {
+ padding: 15px 16px;
+}
+.inbox-page .authority-note {
+ margin-top: 18px;
}
.work-surface__heading {
display: flex;
@@ -587,6 +624,18 @@ td:first-child {
border-inline-start: 3px solid Highlight;
box-shadow: none;
}
+
+ .analysis-conversation-history,
+ .analysis-conversation-thread,
+ .dataset-index-page__card,
+ .dataset-detail-page,
+ .upload-panel,
+ .etl-review-card,
+ .workspace-settings-page__section,
+ .inbox-page .table-scroll {
+ border: 1px solid CanvasText;
+ box-shadow: none;
+ }
}
/* DDA-020..024: route-level dashboard composition. Detailed canvas styles live in dashboard-canvas.css. */
@@ -623,7 +672,7 @@ td:first-child {
display: grid;
grid-template-columns: minmax(360px, 0.92fr) minmax(420px, 1.08fr);
color: #101c2d;
- background: #f7faf9;
+ background: #f4f7fc;
}
.auth-page__story {
position: relative;
@@ -631,27 +680,7 @@ td:first-child {
align-items: stretch;
overflow: hidden;
color: #fff;
- background: linear-gradient(145deg, #0643bd 0%, #0f5fe7 52%, #2b82f6 100%);
-}
-.auth-page__story::before,
-.auth-page__story::after {
- content: '';
- position: absolute;
- border: 1px solid rgb(255 255 255 / 17%);
- border-radius: 999px;
- pointer-events: none;
-}
-.auth-page__story::before {
- width: 520px;
- height: 520px;
- right: -260px;
- bottom: -210px;
-}
-.auth-page__story::after {
- width: 300px;
- height: 300px;
- left: -180px;
- top: 22%;
+ background: #075de8;
}
.auth-page__story-inner {
position: relative;
@@ -772,7 +801,10 @@ td:first-child {
color: #101c2d;
background: #fcfdff;
outline: 0;
- transition: border-color 160ms ease, box-shadow 160ms ease, background-color 160ms ease;
+ transition:
+ border-color 160ms ease,
+ box-shadow 160ms ease,
+ background-color 160ms ease;
}
.auth-form input:hover {
border-color: #9eb7ea;
@@ -793,7 +825,10 @@ td:first-child {
cursor: pointer;
font-size: 0.9rem;
font-weight: 700;
- transition: transform 160ms ease, background-color 160ms ease, box-shadow 160ms ease;
+ transition:
+ transform 160ms ease,
+ background-color 160ms ease,
+ box-shadow 160ms ease;
}
.auth-form__submit:hover:not(:disabled) {
background: #0643bd;
diff --git a/apps/web/src/styles/dashboard-agent.css b/apps/web/src/styles/dashboard-agent.css
index a78b0b42..d6e9f2fc 100644
--- a/apps/web/src/styles/dashboard-agent.css
+++ b/apps/web/src/styles/dashboard-agent.css
@@ -104,12 +104,13 @@
right: max(1rem, env(safe-area-inset-right));
bottom: max(1rem, env(safe-area-inset-bottom));
display: grid;
- align-content: start;
- gap: 1rem;
- width: min(32.5rem, calc(100vw - 2rem));
+ grid-template-rows: auto minmax(0, 1fr);
+ align-content: stretch;
+ gap: 0.875rem;
+ width: min(26.25rem, calc(100vw - 2rem));
max-height: min(46rem, calc(100dvh - 2rem));
- overflow: auto;
- padding: 1.25rem;
+ overflow: hidden;
+ padding: 1rem;
border: 1px solid #d9e3f1;
border-radius: 1.1rem;
background: #ffffff;
@@ -130,9 +131,19 @@
margin: 0;
}
+.dda-dashboard-agent-panel__header h2 {
+ color: #102a63;
+ font-size: 1rem;
+ line-height: 1.3;
+}
+
.dda-dashboard-agent-panel__eyebrow {
- color: #71809a;
- font-size: 0.8125rem;
+ margin-bottom: 0.15rem !important;
+ color: #075de8;
+ font-size: 0.6875rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
}
.dda-dashboard-agent-panel__header button {
@@ -146,6 +157,20 @@
font-size: 1.375rem;
}
+.dda-dashboard-agent-panel__header button svg {
+ width: 1.125rem;
+ height: 1.125rem;
+}
+
+.dda-dashboard-agent-panel .agent-chat-shell {
+ min-height: 0;
+ overflow: hidden;
+}
+
+.dda-dashboard-agent-panel .agent-chat-shell__messages {
+ max-height: none;
+}
+
.dda-dashboard-agent-panel__header button:hover {
background: #edf4ff;
}
@@ -412,7 +437,7 @@
top: 0;
right: 0;
bottom: 0;
- width: min(32.5rem, 78vw);
+ width: min(26.25rem, 78vw);
max-height: none;
border-radius: 1rem 0 0 1rem;
}
diff --git a/apps/web/src/styles/data-intake.css b/apps/web/src/styles/data-intake.css
index 1da4aa26..085106f7 100644
--- a/apps/web/src/styles/data-intake.css
+++ b/apps/web/src/styles/data-intake.css
@@ -334,7 +334,7 @@
position: sticky;
top: 20px;
padding: 22px;
- background: linear-gradient(155deg, #102c61 0%, #173f8d 65%, #0f5fe7 150%);
+ background: #123b84;
color: #e8f2f5;
box-shadow: 0 18px 48px rgb(13 27 47 / 16%);
}
diff --git a/apps/web/src/styles/workspace-shell.css b/apps/web/src/styles/workspace-shell.css
index a90fda20..1664af6d 100644
--- a/apps/web/src/styles/workspace-shell.css
+++ b/apps/web/src/styles/workspace-shell.css
@@ -3,21 +3,23 @@
@import '@fontsource/be-vietnam-pro/600.css';
:root {
- --workspace-canvas: #fbfcfd;
- --workspace-canvas-border: #e4e9ef;
- --workspace-cobalt: #0f5fe7;
+ --workspace-canvas: #f4f7fc;
+ --workspace-canvas-border: #dce5f2;
+ --workspace-cobalt: #075de8;
--workspace-cobalt-deep: #0643bd;
- --workspace-ink: #101c2d;
+ --workspace-ink: #102a63;
--workspace-muted: #68778d;
--workspace-surface: #ffffff;
--workspace-transition: 240ms;
}
.app-shell.app-shell {
+ --sidebar-width: 248px;
+
min-height: 100vh;
display: grid;
grid-template-areas: 'rail topbar' 'rail main';
- grid-template-columns: 72px minmax(0, 1fr);
+ grid-template-columns: var(--sidebar-width) minmax(0, 1fr);
grid-template-rows: 64px minmax(0, 1fr);
background: var(--workspace-canvas);
font-family:
@@ -29,6 +31,10 @@
sans-serif;
}
+.app-shell.app-shell[data-sidebar-collapsed='true'] {
+ --sidebar-width: 72px;
+}
+
.app-shell.app-shell--dashboard {
background: #fbfcfd;
}
@@ -172,10 +178,12 @@
min-height: 100vh;
display: flex;
flex-direction: column;
- align-items: center;
+ align-items: stretch;
overflow: hidden;
color: #ffffff;
background: var(--workspace-cobalt);
+ box-shadow: 8px 0 24px rgb(16 42 99 / 10%);
+ transition: width var(--workspace-transition) ease;
}
.application-rail::after {
@@ -188,12 +196,21 @@
background: var(--workspace-cobalt-deep);
}
+.application-rail__header {
+ min-height: 78px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 12px;
+}
+
.application-rail__brand {
- width: 44px;
+ min-width: 0;
+ flex: 1 1 auto;
height: 52px;
- display: grid;
- place-items: center;
- margin-block: 8px 12px;
+ display: flex;
+ align-items: center;
+ padding: 6px 10px;
border-radius: 14px;
}
@@ -203,39 +220,110 @@
}
.application-rail__brand-mark {
- width: 34px;
- height: 32px;
+ width: 174px;
+ height: 44px;
overflow: hidden;
display: block;
- padding: 4px;
+ padding: 7px 10px;
border-radius: 10px;
background: #ffffff;
box-shadow: 0 5px 14px rgb(6 67 189 / 20%);
}
.application-rail__brand img {
- width: 131px;
- height: 32px;
- max-width: none;
+ width: 146px;
+ height: auto;
+ max-width: 100%;
display: block;
filter: none;
}
+.application-rail__brand-icon {
+ display: none !important;
+}
+
+.application-rail[data-collapsed='true'] .application-rail__header {
+ display: grid;
+ justify-items: center;
+ padding-inline: 8px;
+}
+
+.application-rail[data-collapsed='true'] .application-rail__brand {
+ width: 48px;
+ padding: 6px;
+}
+
+.application-rail[data-collapsed='true'] .application-rail__brand-mark {
+ width: 36px;
+ height: 36px;
+ display: grid;
+ place-items: center;
+ padding: 4px;
+ overflow: visible;
+}
+
+.application-rail[data-collapsed='true'] .application-rail__brand-wordmark {
+ display: none;
+}
+
+.application-rail[data-collapsed='true'] .application-rail__brand-icon {
+ display: block !important;
+ width: 28px;
+ height: 28px;
+ max-width: none;
+ object-fit: contain;
+ filter: brightness(0) invert(1);
+}
+
+.application-rail__collapse {
+ width: 36px;
+ height: 36px;
+ flex: 0 0 36px;
+ display: grid;
+ place-items: center;
+ padding: 0;
+ border: 1px solid rgb(255 255 255 / 22%);
+ border-radius: 10px;
+ color: #ffffff;
+ background: rgb(255 255 255 / 10%);
+ cursor: pointer;
+}
+
+.application-rail__collapse:hover,
+.application-rail__collapse:focus-visible {
+ background: rgb(255 255 255 / 20%);
+}
+
+.application-rail[data-collapsed='true'] .application-rail__collapse {
+ grid-row: 2;
+}
+
+.application-rail__group-label {
+ margin: 12px 20px 7px;
+ color: #ffffff;
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
.application-rail__items {
width: 100%;
display: grid;
gap: 8px;
margin: 0;
- padding: 0 10px;
+ padding: 0 12px;
list-style: none;
}
.application-rail__link {
- min-width: 48px;
- min-height: 48px;
+ min-width: 0;
+ min-height: 44px;
display: flex;
align-items: center;
- justify-content: center;
+ justify-content: flex-start;
+ gap: 12px;
+ padding: 0 13px;
border: 1px solid transparent;
border-radius: 14px;
color: rgb(255 255 255 / 86%);
@@ -255,12 +343,44 @@
}
.application-rail__label {
+ min-width: 0;
+ overflow: hidden;
+ font-size: 13px;
+ font-weight: 600;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.application-rail__secondary {
+ margin-top: auto;
+ padding-bottom: 18px;
+}
+
+.application-rail__items--secondary {
+ gap: 4px;
+}
+
+.application-rail__items--secondary .application-rail__link {
+ color: #ffffff;
+}
+
+.application-rail[data-collapsed='true'] .application-rail__items {
+ padding-inline: 10px;
+}
+
+.application-rail[data-collapsed='true'] .application-rail__link {
+ justify-content: center;
+ padding-inline: 0;
+}
+
+.application-rail[data-collapsed='true'] .application-rail__label,
+.application-rail[data-collapsed='true'] .application-rail__group-label {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
- clip: rect(0 0 0 0);
+ clip-path: inset(50%);
white-space: nowrap;
}
@@ -280,6 +400,12 @@
padding: 0;
}
+.app-shell .main-workspace--analysis {
+ min-height: 0;
+ padding: 0;
+ overflow: hidden;
+}
+
.dashboard-workspace {
min-height: calc(100vh - 64px);
display: grid;
@@ -448,14 +574,16 @@
background: #edf2ff;
}
+.sr-only,
.dashboard-workspace .sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
- clip: rect(0 0 0 0);
+ clip-path: inset(50%);
white-space: nowrap;
+ border: 0;
}
@media (min-width: 1024px) {
@@ -470,7 +598,7 @@
height: 1px;
margin: -1px;
overflow: hidden;
- clip: rect(0 0 0 0);
+ clip-path: inset(50%);
white-space: nowrap;
}
}
@@ -517,12 +645,16 @@
box-shadow: 14px 0 30px rgb(21 47 117 / 24%);
}
+ .application-rail--mobile .application-rail__header {
+ padding: 0 52px 0 0;
+ }
+
.application-rail--mobile[hidden] {
display: none;
}
.application-rail__brand {
- margin-inline-start: 2px;
+ max-width: 204px;
}
.application-rail__mobile-close {
@@ -611,71 +743,206 @@
}
}
-/* Approved dashboard composition: the rail and analysis history own the full height,
- while the dashboard context bar starts at the canvas edge. DDA-020/026, WEB-013. */
-.app-shell.app-shell--dashboard {
- --dashboard-rail-width: 80px;
- --dashboard-history-width: clamp(300px, 22vw, 360px);
- height: 100vh;
- overflow: hidden;
- grid-template-areas: 'rail main';
- grid-template-columns: var(--dashboard-rail-width) minmax(0, 1fr);
- grid-template-rows: minmax(0, 1fr);
- background: #f7faff;
+.agent-chat-shell {
+ min-height: 0;
+ display: grid;
+ grid-template-rows: auto auto minmax(150px, 1fr) auto auto auto auto;
+ gap: 12px;
}
-.app-shell--dashboard .application-rail {
- height: 100vh;
- min-height: 0;
- background: #0d5de5;
- box-shadow: 10px 0 30px rgb(15 83 207 / 12%);
+.agent-chat-shell__toolbar {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: end;
+ gap: 10px;
}
-.app-shell--dashboard .application-rail::after {
- display: none;
+.agent-chat-shell__toolbar label {
+ min-width: 0;
+ display: grid;
+ gap: 5px;
+ color: #6b7892;
+ font-size: 11px;
+ font-weight: 700;
+}
+
+.agent-chat-shell__toolbar select {
+ width: 100%;
+ min-height: 40px;
+ padding: 0 34px 0 11px;
+ border: 1px solid #d7e1ef;
+ border-radius: 10px;
+ color: #102a63;
+ background: #ffffff;
+ font: inherit;
+ font-size: 13px;
+ font-weight: 600;
+}
+
+.agent-chat-shell__new,
+.agent-chat-shell__analysis-link {
+ min-height: 40px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0 12px;
+ border: 1px solid #cbd9ef;
+ border-radius: 10px;
+ color: #075de8;
+ background: #ffffff;
+ font-size: 12px;
+ font-weight: 700;
+ text-decoration: none;
}
-.app-shell--dashboard .application-rail__brand {
- visibility: hidden;
- margin-block: 40px 28px;
+.agent-chat-shell__context {
+ margin: 0;
+ padding: 9px 11px;
+ border-inline-start: 3px solid #075de8;
+ color: #52617b;
+ background: #f4f7fc;
+ font-size: 12px;
+ line-height: 1.5;
}
-.app-shell--dashboard .application-rail__items {
- gap: 24px;
- padding-inline: 12px;
+.agent-chat-shell__messages {
+ min-height: 150px;
+ max-height: min(44vh, 420px);
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ overflow-y: auto;
+ padding: 12px;
+ border: 1px solid #e0e7f1;
+ border-radius: 14px;
+ background: #f8faff;
}
-.app-shell--dashboard .application-rail__link {
- min-width: 56px;
- min-height: 56px;
- border-radius: 16px;
+.agent-chat-shell__empty {
+ margin: auto;
+ color: #71809a;
+ font-size: 12px;
+ text-align: center;
}
-.app-shell--dashboard .application-rail__link.is-active {
- border-color: #ffffff;
- color: #0d5de5;
+.agent-chat-shell__message {
+ max-width: 88%;
+ padding: 10px 12px;
+ border: 1px solid #dbe5f2;
+ border-radius: 13px;
+ color: #1a2d53;
background: #ffffff;
- box-shadow: 0 12px 28px rgb(3 41 117 / 26%);
}
-.app-shell--dashboard .workspace-topbar--dashboard {
- position: fixed;
- z-index: 50;
- inset-block-start: 0;
- inset-inline-start: calc(var(--dashboard-rail-width) + var(--dashboard-history-width));
- inset-inline-end: 0;
- height: 68px;
- padding-inline: clamp(20px, 2.5vw, 40px);
- border-bottom-color: #dce5f2;
- background: rgb(255 255 255 / 94%);
- box-shadow: 0 6px 20px rgb(22 67 146 / 4%);
- backdrop-filter: blur(16px);
- transition: inset-inline-start var(--workspace-transition) var(--db-motion-easing-standard);
+.agent-chat-shell__message--user {
+ align-self: flex-end;
+ border-color: #075de8;
+ color: #ffffff;
+ background: #075de8;
+}
+
+.agent-chat-shell__message p,
+.agent-chat-shell__message time,
+.agent-chat-shell__state {
+ margin: 0;
+}
+
+.agent-chat-shell__message p {
+ font-size: 13px;
+ line-height: 1.55;
+}
+
+.agent-chat-shell__message time {
+ display: block;
+ margin-top: 5px;
+ color: inherit;
+ font-size: 10px;
+ opacity: 0.72;
+}
+
+.agent-chat-shell__state {
+ color: #53627e;
+ font-size: 12px;
+ line-height: 1.5;
+}
+
+.agent-chat-shell__composer {
+ display: grid;
+ gap: 6px;
+}
+
+.agent-chat-shell__composer > label {
+ color: #52617b;
+ font-size: 11px;
+ font-weight: 700;
+}
+
+.agent-chat-shell__composer > div {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: end;
+ gap: 8px;
+ padding: 8px;
+ border: 1px solid #ccd8ea;
+ border-radius: 14px;
+ background: #ffffff;
+}
+
+.agent-chat-shell__composer textarea {
+ width: 100%;
+ min-height: 58px;
+ resize: vertical;
+ padding: 7px;
+ border: 0;
+ color: #102a63;
+ background: transparent;
+ font: inherit;
+ font-size: 13px;
+ line-height: 1.5;
+}
+
+.agent-chat-shell__composer textarea:focus {
+ outline: 0;
}
-.app-shell--dashboard:has(.dashboard-workspace[data-history-collapsed='true'])
- .workspace-topbar--dashboard {
- inset-inline-start: var(--dashboard-rail-width);
+.agent-chat-shell__composer button {
+ min-width: 62px;
+ min-height: 38px;
+ padding: 0 12px;
+ border: 0;
+ border-radius: 10px;
+ color: #ffffff;
+ background: #075de8;
+ font-weight: 700;
+}
+
+.agent-chat-shell__composer button:disabled {
+ color: #8190aa;
+ background: #e7edf6;
+}
+
+.agent-chat-shell__analysis-link {
+ justify-self: start;
+ min-height: 34px;
+ border-color: transparent;
+ padding-inline: 0;
+ background: transparent;
+}
+
+.agent-chat-shell :is(button, select, textarea, a):focus-visible {
+ outline: 3px solid #9cbcff;
+ outline-offset: 2px;
+}
+
+/* WEB-013/DDA-020: Dashboard uses the shared shell and the full pale-blue canvas. */
+.app-shell.app-shell--dashboard {
+ min-height: 100vh;
+ background: #f4f7fc;
+}
+
+.app-shell--dashboard .workspace-topbar--dashboard {
+ background: #ffffff;
+ box-shadow: 0 4px 8px rgb(16 42 99 / 4%);
}
.workspace-topbar__dashboard-breadcrumb {
@@ -728,146 +995,10 @@
}
.app-shell--dashboard .main-workspace--dashboard {
- grid-area: main;
- height: 100vh;
- min-height: 0;
- overflow: hidden;
-}
-
-.app-shell--dashboard .dashboard-workspace,
-.app-shell--dashboard .dashboard-workspace[data-history-collapsed='true'] {
- height: 100vh;
- min-height: 0;
- grid-template-columns: var(--dashboard-history-width) minmax(0, 1fr);
- background: #f7faff;
-}
-
-.app-shell--dashboard .dashboard-workspace[data-history-collapsed='true'] {
- grid-template-columns: 0 minmax(0, 1fr);
-}
-
-.app-shell--dashboard .analysis-history-panel {
- position: relative;
- height: 100vh;
- padding: 20px 24px 28px;
- overflow-y: auto;
- border-inline-end-color: #dfe6f1;
- background: #ffffff;
-}
-
-.analysis-history-panel__brand {
- min-height: 84px;
- display: grid;
- place-items: center;
- padding: 16px;
- border: 1px solid #edf1f8;
- border-radius: 18px;
- background: #ffffff;
- box-shadow: 0 12px 28px rgb(24 67 143 / 8%);
-}
-
-.analysis-history-panel__brand img {
- width: min(204px, 100%);
- height: auto;
- display: block;
-}
-
-.app-shell--dashboard .analysis-history-panel__header {
- min-height: 0;
-}
-
-.app-shell--dashboard .analysis-history-panel__header > div {
- position: absolute;
- width: 1px;
- height: 1px;
- margin: -1px;
- overflow: hidden;
- clip: rect(0 0 0 0);
- white-space: nowrap;
-}
-
-.app-shell--dashboard .analysis-history-panel__collapse {
- position: absolute;
- inset-block-start: 28px;
- inset-inline-end: 30px;
- width: 32px;
- min-width: 32px;
- min-height: 32px;
- border-color: transparent;
- background: rgb(255 255 255 / 88%);
- opacity: 0;
- transition: opacity 150ms ease;
-}
-
-.app-shell--dashboard .analysis-history-panel:hover .analysis-history-panel__collapse,
-.app-shell--dashboard .analysis-history-panel__collapse:focus-visible {
- opacity: 1;
-}
-
-.app-shell--dashboard .analysis-history-panel__create {
- min-height: 52px;
- margin-block: 18px 24px;
- border-radius: 14px;
- background: #0d5de5;
- box-shadow: 0 10px 22px rgb(13 93 229 / 22%);
- font-size: 15px;
- font-weight: 700;
-}
-
-.app-shell--dashboard .analysis-history-panel__search-label {
- position: absolute;
- width: 1px;
- height: 1px;
- margin: -1px;
- overflow: hidden;
- clip: rect(0 0 0 0);
- white-space: nowrap;
-}
-
-.app-shell--dashboard .analysis-history-panel__search {
- min-height: 40px;
- padding-inline: 14px;
- border-color: #e3eaf4;
- border-radius: 12px;
- background: #f8faff;
-}
-
-.app-shell--dashboard .analysis-history-panel__items {
- gap: 10px;
- margin-block-start: 22px;
-}
-
-.app-shell--dashboard .analysis-history-panel__item {
- gap: 5px;
- padding: 14px;
- border-radius: 12px;
-}
-
-.app-shell--dashboard .analysis-history-panel__item-kind {
- display: none;
-}
-
-.app-shell--dashboard .analysis-history-panel__item-title {
- color: #152447;
- font-size: 14px;
-}
-
-.app-shell--dashboard .analysis-history-panel__item-updated {
- color: #73829f;
- font-size: 12px;
-}
-
-.app-shell--dashboard .analysis-history-panel__item.is-active {
- border-color: transparent;
- background: #eef4ff;
- box-shadow: inset 3px 0 0 #0d5de5;
-}
-
-.app-shell--dashboard .dashboard-workspace__stage {
- height: 100vh;
- padding: 88px clamp(18px, 2vw, 32px) 28px;
+ min-width: 0;
+ padding: clamp(18px, 2.4vw, 32px);
overflow: auto;
- background: #f7faff;
+ background: #f4f7fc;
}
.workspace-topbar--dashboard[data-dashboard-presentation='vertical']
@@ -890,57 +1021,19 @@
}
}
-@media (max-width: 1023px) {
- .app-shell.app-shell--dashboard {
- --dashboard-history-width: min(360px, calc(100vw - 100px));
- }
-
- .app-shell--dashboard .workspace-topbar--dashboard {
- inset-inline-start: var(--dashboard-rail-width);
- }
-
- .app-shell--dashboard .dashboard-workspace,
- .app-shell--dashboard .dashboard-workspace[data-history-collapsed='true'] {
- grid-template-columns: minmax(0, 1fr);
- }
-}
-
@media (max-width: 767px) {
- .app-shell.app-shell--dashboard {
- height: auto;
- min-height: 100vh;
- overflow: visible;
- grid-template-areas: 'topbar' 'main';
- grid-template-columns: minmax(0, 1fr);
- grid-template-rows: 60px minmax(0, 1fr);
- }
-
- .app-shell--dashboard .workspace-topbar--dashboard {
- position: static;
- grid-area: topbar;
- height: 60px;
- padding-inline: 12px;
- }
-
.app-shell--dashboard .workspace-topbar__dashboard-breadcrumb strong {
display: none;
}
- .app-shell--dashboard .main-workspace--dashboard,
- .app-shell--dashboard .dashboard-workspace,
- .app-shell--dashboard .dashboard-workspace[data-history-collapsed='true'],
- .app-shell--dashboard .dashboard-workspace__stage {
- height: auto;
- min-height: 0;
- overflow: visible;
- }
-
- .app-shell--dashboard .dashboard-workspace__stage {
- padding: 14px;
+ .app-shell--dashboard .main-workspace--dashboard {
+ padding: 12px;
}
+}
- .app-shell--dashboard .analysis-history-panel--mobile {
- height: 100dvh;
- padding: 18px;
+@media (prefers-reduced-motion: reduce) {
+ .app-shell,
+ .application-rail {
+ transition: none;
}
}
diff --git a/apps/web/test/analysis-destination.test.tsx b/apps/web/test/analysis-destination.test.tsx
index dd6bb96d..0eef2ec5 100644
--- a/apps/web/test/analysis-destination.test.tsx
+++ b/apps/web/test/analysis-destination.test.tsx
@@ -95,6 +95,63 @@ describe('[DDA-055][DDA-056] Analysis destination', () => {
expect(submitted).toEqual(['So sánh với tháng trước']);
});
+ it('starts from useful analysis prompts without sending until the user confirms', async () => {
+ const user = userEvent.setup();
+ const submitted: string[] = [];
+ render(
+ submitted.push(message)}
+ />,
+ );
+
+ expect(screen.getByRole('heading', { name: 'Trợ lý DataBreeze' })).toBeTruthy();
+ await user.click(screen.getByRole('button', { name: 'Tìm điểm bất thường' }));
+
+ expect(
+ (screen.getByRole('textbox', { name: 'Nhập câu hỏi phân tích' }) as HTMLTextAreaElement)
+ .value,
+ ).toBe('Tìm điểm bất thường trong dữ liệu này');
+ expect(document.activeElement).toBe(
+ screen.getByRole('textbox', { name: 'Nhập câu hỏi phân tích' }),
+ );
+ expect(submitted).toEqual([]);
+
+ await user.click(screen.getByRole('button', { name: 'Gửi câu hỏi' }));
+ expect(submitted).toEqual(['Tìm điểm bất thường trong dữ liệu này']);
+ });
+
+ it('creates a new analysis only through the explicit history action', async () => {
+ const user = userEvent.setup();
+ let created = 0;
+ render(
+ {
+ created += 1;
+ }}
+ />,
+ );
+
+ await user.click(screen.getByRole('button', { name: 'Phân tích mới' }));
+ expect(created).toBe(1);
+ });
+
it('collapses history without removing the active thread', async () => {
const user = userEvent.setup();
render(
diff --git a/apps/web/test/application-rail.test.tsx b/apps/web/test/application-rail.test.tsx
index 9c85784f..9533329d 100644
--- a/apps/web/test/application-rail.test.tsx
+++ b/apps/web/test/application-rail.test.tsx
@@ -1,5 +1,6 @@
import { render, screen } from '@testing-library/react';
-import { describe, expect, it } from 'vitest';
+import userEvent from '@testing-library/user-event';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ApplicationBoundary, createAppRouter } from '../src/app/app.tsx';
function renderDashboard(pathname = '/vi-VN/dashboards') {
@@ -9,6 +10,14 @@ function renderDashboard(pathname = '/vi-VN/dashboards') {
}
describe('application rail', () => {
+ beforeEach(() => {
+ globalThis.localStorage.clear();
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
it('keeps the three primary destinations as labeled icon navigation and marks the current route', async () => {
renderDashboard();
@@ -32,4 +41,61 @@ describe('application rail', () => {
expect(screen.getByText('Bright Cloud')).toBeTruthy();
expect(screen.getByText('Bức tranh kinh doanh')).toBeTruthy();
});
+
+ it('starts expanded, collapses to icon-only navigation, and remembers the preference', async () => {
+ const user = userEvent.setup();
+ renderDashboard();
+
+ await screen.findByRole('navigation', { name: 'Điều hướng chính' });
+ expect(screen.getByRole('link', { name: 'Bảng điều khiển' }).textContent).toContain(
+ 'Bảng điều khiển',
+ );
+
+ await user.click(screen.getByRole('button', { name: 'Thu gọn thanh bên' }));
+
+ const navigation = screen.getByRole('navigation', { name: 'Điều hướng chính' });
+ expect(navigation.getAttribute('data-collapsed')).toBe('true');
+ expect(globalThis.localStorage.getItem('databreeze.sidebar.compact.v1')).toBe('true');
+ expect(screen.getByRole('link', { name: 'Bảng điều khiển' }).getAttribute('title')).toBe(
+ 'Bảng điều khiển',
+ );
+ });
+
+ it('renders authorized Inbox, Reviews, and Settings as quieter workspace tools', async () => {
+ renderDashboard();
+
+ await screen.findByRole('navigation', { name: 'Điều hướng chính' });
+ expect(screen.getByRole('link', { name: 'Hộp thư đến' })).toBeTruthy();
+ expect(screen.getByRole('link', { name: 'Nội dung cần xem xét' })).toBeTruthy();
+ expect(screen.getByRole('link', { name: 'Cài đặt' })).toBeTruthy();
+ });
+
+ it('uses compact sidebar by default at tablet width when no preference exists', async () => {
+ vi.stubGlobal('matchMedia', (query: string) => ({
+ matches: query === '(min-width: 768px) and (max-width: 1023px)',
+ media: query,
+ onchange: null,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ }));
+
+ renderDashboard();
+
+ expect(
+ (await screen.findByRole('navigation', { name: 'Điều hướng chính' })).getAttribute(
+ 'data-collapsed',
+ ),
+ ).toBe('true');
+ });
+
+ it('keeps the Dashboard canvas free of analysis-history controls', async () => {
+ renderDashboard();
+
+ await screen.findByRole('region', { name: 'Bề mặt bảng điều khiển' });
+ expect(screen.queryByRole('button', { name: 'Phân tích mới' })).toBeNull();
+ expect(screen.queryByLabelText('Tìm lịch sử phân tích')).toBeNull();
+ });
});
diff --git a/apps/web/test/dashboard-agent-panel.test.tsx b/apps/web/test/dashboard-agent-panel.test.tsx
index 2d7c0bff..d958e226 100644
--- a/apps/web/test/dashboard-agent-panel.test.tsx
+++ b/apps/web/test/dashboard-agent-panel.test.tsx
@@ -1,6 +1,6 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
-import { describe, expect, it } from 'vitest';
+import { describe, expect, it, vi } from 'vitest';
import { AnalystPanel } from '../src/features/dashboards/analyst-panel.tsx';
import { DashboardAgentPanel } from '../src/features/dashboards/dashboard-agent-panel.tsx';
@@ -21,6 +21,57 @@ const preview = {
};
describe('dashboard-local agent panel [DDA-015][DDA-017][DDA-024][WEB-014]', () => {
+ it('looks and behaves like a contextual chat with conversation switching', async () => {
+ const user = userEvent.setup();
+ const onSelectConversation = vi.fn();
+ render(
+ undefined}
+ onSelectConversation={onSelectConversation}
+ open
+ target={{ pageId: 'page-1', pageTitle: { vi: 'Tổng quan', en: 'Overview' } }}
+ />,
+ );
+
+ expect(screen.getByText('Cho tôi xem doanh thu theo khu vực')).toBeTruthy();
+ await user.selectOptions(
+ screen.getByRole('combobox', { name: 'Chuyển hội thoại' }),
+ 'conversation-orders',
+ );
+ expect(onSelectConversation).toHaveBeenCalledWith('conversation-orders');
+ expect(screen.getByRole('link', { name: 'Mở trong Phân tích' }).getAttribute('href')).toBe(
+ '/vi-VN/analysis?conversation=conversation-sales',
+ );
+ });
+
it('opens from the persistent icon, identifies the current target, and returns focus on Escape', async () => {
const user = userEvent.setup();
render();
@@ -55,7 +106,7 @@ describe('dashboard-local agent panel [DDA-015][DDA-017][DDA-024][WEB-014]', ()
screen.getByRole('textbox', { name: 'Câu hỏi cho trợ lý biểu đồ' }),
'Doanh thu theo khu vực',
);
- await user.click(screen.getByRole('button', { name: 'Tạo đề xuất biểu đồ' }));
+ await user.click(screen.getByRole('button', { name: 'Gửi' }));
expect(screen.getByRole('alert').textContent).toBe(
'Trợ lý AI hiện không khả dụng. Bạn vẫn có thể tạo kế hoạch phân tích có kiểm soát thủ công.',
diff --git a/apps/web/test/data-destination.test.tsx b/apps/web/test/data-destination.test.tsx
index 6cbb0ee2..fcf62c24 100644
--- a/apps/web/test/data-destination.test.tsx
+++ b/apps/web/test/data-destination.test.tsx
@@ -78,6 +78,11 @@ describe('[DDA-009][DDA-052][DDA-053] Data destination', () => {
);
expect(screen.getByRole('heading', { name: 'Dữ liệu' })).toBeTruthy();
+ expect(
+ screen.getByText(
+ 'Quản lý bộ dữ liệu, tệp nguồn, phiên bản và các mục cần xem xét trong phạm vi được cấp quyền.',
+ ),
+ ).toBeTruthy();
expect(screen.getByRole('button', { name: /Doanh thu TP.HCM/u })).toBeTruthy();
await user.click(screen.getByRole('button', { name: /Doanh thu TP.HCM/u }));
diff --git a/apps/web/test/data-pipeline-route.test.tsx b/apps/web/test/data-pipeline-route.test.tsx
index fa602e77..455acf96 100644
--- a/apps/web/test/data-pipeline-route.test.tsx
+++ b/apps/web/test/data-pipeline-route.test.tsx
@@ -1,7 +1,9 @@
import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';
import { ApplicationBoundary, createAppRouter } from '../src/app/app.tsx';
+import { DataPipelinePage } from '../src/features/data-intake/data-pipeline-page.tsx';
describe('data pipeline route composition [DDA-002][DDA-006]', () => {
it('composes intake upload and ETL review on the reviews route without demo mode', async () => {
@@ -37,4 +39,30 @@ describe('data pipeline route composition [DDA-002][DDA-006]', () => {
screen.getByRole('button', { name: 'Accept ETL proposal' }).hasAttribute('disabled'),
).toBe(true);
});
+
+ it('keeps local demo intake selectable and testable without inventing tenant authority', async () => {
+ const user = userEvent.setup();
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByRole('heading', { name: 'Tải tệp CSV/XLSX' })).toBeTruthy();
+ expect(screen.getByLabelText('Chọn tệp')).toBeTruthy();
+ expect(
+ screen.queryByText(
+ 'Cần ngữ cảnh tenant trước khi tải lên hoặc chấp nhận ETL. Không có thay đổi nào được gửi.',
+ ),
+ ).toBeNull();
+ await user.upload(
+ screen.getByLabelText('Chọn tệp'),
+ new File(['date,revenue\n2026-08-14,4200000\n'], 'doanh-thu.csv', {
+ type: 'text/csv',
+ }),
+ );
+ await user.click(screen.getByRole('button', { name: 'Tải lên' }));
+ expect(await screen.findByText(/Đã gửi tệp vào Inbox/u)).toBeTruthy();
+ expect(screen.queryByText(/4200000/u)).toBeNull();
+ });
});
diff --git a/apps/web/test/data-route-page.test.tsx b/apps/web/test/data-route-page.test.tsx
index 3e78b00a..bdfcb0b5 100644
--- a/apps/web/test/data-route-page.test.tsx
+++ b/apps/web/test/data-route-page.test.tsx
@@ -23,6 +23,7 @@ describe('[WEB-020][WEB-021][WEB-024] data route loading states', () => {
render();
+ expect(screen.getByRole('heading', { name: 'Dữ liệu' })).toBeTruthy();
expect(screen.getByRole('status').textContent).toContain('Đang tải dữ liệu');
});
@@ -35,6 +36,7 @@ describe('[WEB-020][WEB-021][WEB-024] data route loading states', () => {
render();
await waitFor(() => expect(screen.getByRole('alert')).toBeTruthy());
+ expect(screen.getByRole('heading', { name: 'Dữ liệu' })).toBeTruthy();
expect(screen.getByRole('alert').textContent).toContain('Không thể tải dữ liệu');
expect(screen.queryByText('Doanh thu TP.HCM')).toBeNull();
});
diff --git a/apps/web/test/floating-agent.test.tsx b/apps/web/test/floating-agent.test.tsx
index 3bffba0f..743d0cdb 100644
--- a/apps/web/test/floating-agent.test.tsx
+++ b/apps/web/test/floating-agent.test.tsx
@@ -1,5 +1,6 @@
-import { render, screen } from '@testing-library/react';
-import { describe, expect, it } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, describe, expect, it, vi } from 'vitest';
import { ApplicationBoundary, createAppRouter } from '../src/app/app.tsx';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { FloatingAgentButton } from '../src/features/agent/floating-agent-button.tsx';
@@ -7,12 +8,46 @@ import { FloatingAgentPanel } from '../src/features/agent/floating-agent-panel.t
import { createAgentStore } from '../src/features/agent/agent-store.ts';
describe('floating agent surfaces', () => {
+ afterEach(() => vi.unstubAllEnvs());
+
it('shows the floating agent on the composed dashboard route', async () => {
const router = createAppRouter({ initialEntries: ['/vi-VN/dashboards'] });
render();
expect(await screen.findByRole('button', { name: 'Mở trợ lý biểu đồ' })).toBeTruthy();
});
+ it('adds compatible demo charts only after the explicit canvas confirmation', async () => {
+ vi.stubEnv('VITE_DATABREEZE_DEMO_MODE', 'true');
+ const user = userEvent.setup();
+ const router = createAppRouter({ initialEntries: ['/vi-VN/dashboards'] });
+ render();
+
+ await screen.findByTestId('widget-00000000-0000-4000-8000-00000000001d');
+ const initialWidgetCount = document.querySelectorAll('.dda-widget-frame').length;
+ await user.click(await screen.findByRole('button', { name: 'Mở trợ lý biểu đồ' }));
+ await user.type(
+ screen.getByRole('textbox', { name: 'Câu hỏi cho trợ lý biểu đồ' }),
+ 'Cho tôi xem doanh thu theo khu vực',
+ );
+ await user.click(screen.getByRole('button', { name: 'Gửi' }));
+
+ const barOption = await screen.findByRole('option', { name: /Cột/u });
+ const lineOption = screen.getByRole('option', { name: /Đường/u });
+ await user.click(barOption);
+ await user.click(lineOption);
+ expect(document.querySelectorAll('.dda-widget-frame').length).toBe(initialWidgetCount);
+
+ await user.click(screen.getByRole('button', { name: 'Thêm 2 biểu đồ vào canvas' }));
+
+ expect(await screen.findByText('Đã thêm 2 biểu đồ vào canvas.')).toBeTruthy();
+ expect(document.querySelectorAll('.dda-widget-frame').length).toBe(initialWidgetCount + 2);
+ await waitFor(() => {
+ expect(document.activeElement).toBe(
+ document.querySelectorAll('.dda-widget-frame')[initialWidgetCount],
+ );
+ });
+ });
+
it('shows the floating agent on dashboard and data routes', () => {
const store = createAgentStore();
render(
@@ -33,6 +68,71 @@ describe('floating agent surfaces', () => {
expect(screen.getByRole('button', { name: 'Mở trợ lý' })).toBeTruthy();
});
+ it('opens a contextual DataBreeze assistant card instead of an empty panel', async () => {
+ const user = userEvent.setup();
+ const store = createAgentStore();
+ store.setActiveConversation({
+ conversationId: 'conversation-1',
+ title: 'Doanh thu theo khu vực',
+ datasetLabel: 'Bán hàng toàn quốc',
+ datasetVersionLabel: 'Phiên bản 12',
+ });
+ render(
+
+
+
+ ,
+ );
+
+ await user.click(screen.getByRole('button', { name: 'Mở trợ lý' }));
+
+ expect(screen.getByRole('heading', { name: 'Trợ lý DataBreeze' })).toBeTruthy();
+ expect(screen.getByText('Bán hàng toàn quốc · Phiên bản 12')).toBeTruthy();
+ expect(screen.getByRole('link', { name: 'Mở trong Phân tích' }).getAttribute('href')).toBe(
+ '/vi-VN/analysis?conversation=conversation-1',
+ );
+ });
+
+ it('switches between authorized conversations and opens the same thread in Analysis', async () => {
+ const user = userEvent.setup();
+ const store = createAgentStore();
+ store.setConversations([
+ {
+ conversationId: 'conversation-sales',
+ title: 'Doanh thu theo khu vực',
+ datasetLabel: 'Bán hàng toàn quốc',
+ datasetVersionLabel: 'Phiên bản 12',
+ },
+ {
+ conversationId: 'conversation-orders',
+ title: 'Đơn hàng bất thường',
+ datasetLabel: 'Tồn kho cửa hàng',
+ datasetVersionLabel: 'Phiên bản 7',
+ },
+ ]);
+ render(
+
+
+
+ ,
+ );
+
+ await user.click(screen.getByRole('button', { name: 'Mở trợ lý' }));
+ await user.selectOptions(
+ screen.getByRole('combobox', { name: 'Chuyển hội thoại' }),
+ 'conversation-orders',
+ );
+
+ expect(store.getActiveConversation()?.conversationId).toBe('conversation-orders');
+ expect(screen.getByText('Tồn kho cửa hàng · Phiên bản 7')).toBeTruthy();
+ expect(screen.getByRole('link', { name: 'Mở trong Phân tích' }).getAttribute('href')).toBe(
+ '/vi-VN/analysis?conversation=conversation-orders',
+ );
+ expect(screen.getByRole('link', { name: 'Hội thoại mới' }).getAttribute('href')).toBe(
+ '/vi-VN/analysis?new=1',
+ );
+ });
+
it('does not render a second floating agent on analysis', async () => {
const router = createAppRouter({ initialEntries: ['/vi-VN/analysis'] });
render();
diff --git a/apps/web/test/navigation-access.test.tsx b/apps/web/test/navigation-access.test.tsx
index a9d6fb4d..4158c180 100644
--- a/apps/web/test/navigation-access.test.tsx
+++ b/apps/web/test/navigation-access.test.tsx
@@ -1,6 +1,6 @@
import { PERMISSIONS_V1 } from '@databreeze/domain/permissions/v1';
import { render, screen } from '@testing-library/react';
-import { describe, expect, it } from 'vitest';
+import { describe, expect, it, vi } from 'vitest';
import { ApplicationBoundary, createAppRouter, filterNavigationItems } from '../src/app/app.tsx';
import { UDW_PRIMARY_NAV_ITEMS_V1 } from '../src/app/unified-primary-navigation.ts';
@@ -35,9 +35,38 @@ describe('build-time governed navigation', () => {
expect(screen.getByRole('link', { name: 'Data' })).toBeTruthy();
expect(screen.queryByRole('link', { name: 'Jobs' })).toBeNull();
expect(screen.queryByRole('link', { name: 'Devices' })).toBeNull();
+ expect(screen.getByRole('link', { name: 'Reviews' })).toBeTruthy();
+ expect(screen.queryByRole('link', { name: 'Settings' })).toBeNull();
expect(UDW_PRIMARY_NAV_ITEMS_V1).toHaveLength(3);
});
+ it('keeps the standalone brand mark available when the rail is compact', async () => {
+ const originalMatchMedia = globalThis.matchMedia;
+ globalThis.matchMedia = vi.fn().mockImplementation((query: string) => ({
+ addEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ matches: query === '(min-width: 768px) and (max-width: 1023px)',
+ media: query,
+ onchange: null,
+ removeEventListener: vi.fn(),
+ }));
+
+ try {
+ const router = createAppRouter({ initialEntries: ['/en/dashboards'] });
+ render();
+
+ const navigation = await screen.findByRole('navigation', { name: 'Primary navigation' });
+ expect(navigation.getAttribute('data-collapsed')).toBe('true');
+ expect(navigation.querySelector('.application-rail__brand-wordmark')).toBeTruthy();
+ expect(navigation.querySelector('.application-rail__brand-icon')).toBeTruthy();
+ expect(
+ navigation.querySelector('.application-rail__brand-icon')?.getAttribute('src'),
+ ).toContain('install-icon-192');
+ } finally {
+ globalThis.matchMedia = originalMatchMedia;
+ }
+ });
+
it('presents the dashboard breadcrumb as semantic content instead of inert controls', async () => {
const router = createAppRouter({ initialEntries: ['/en/dashboards'] });
render();
diff --git a/apps/web/test/unified-navigation.test.tsx b/apps/web/test/unified-navigation.test.tsx
index 4cde3757..5c2a8e22 100644
--- a/apps/web/test/unified-navigation.test.tsx
+++ b/apps/web/test/unified-navigation.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from '@testing-library/react';
+import { render, screen, within } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { ApplicationBoundary, createAppRouter } from '../src/app/app.tsx';
import {
@@ -24,14 +24,16 @@ describe('unified primary navigation', () => {
expect([...vi, ...en].every((label) => !label.includes('—'))).toBe(true);
});
- it('renders only the three primary links in the signed-in shell', async () => {
+ it('renders three primary destinations separately from authorized workspace tools', async () => {
const router = createAppRouter({ initialEntries: ['/vi-VN/dashboards'] });
render();
- expect(await screen.findByRole('link', { name: 'Bảng điều khiển' })).toBeTruthy();
- expect(screen.getByRole('link', { name: 'Phân tích' })).toBeTruthy();
- expect(screen.getByRole('link', { name: 'Dữ liệu' })).toBeTruthy();
- expect(screen.queryByRole('link', { name: 'Hộp thư đến' })).toBeNull();
+ const primary = await screen.findByRole('list', { name: 'Không gian làm việc' });
+ expect(within(primary).getAllByRole('link')).toHaveLength(3);
+ expect(within(primary).getByRole('link', { name: 'Bảng điều khiển' })).toBeTruthy();
+ expect(within(primary).getByRole('link', { name: 'Phân tích' })).toBeTruthy();
+ expect(within(primary).getByRole('link', { name: 'Dữ liệu' })).toBeTruthy();
+ expect(screen.getByRole('link', { name: 'Hộp thư đến' })).toBeTruthy();
expect(screen.queryByRole('link', { name: 'Jobs' })).toBeNull();
});
});
diff --git a/apps/web/test/vite-dev-proxy.test.ts b/apps/web/test/vite-dev-proxy.test.ts
new file mode 100644
index 00000000..a86b6653
--- /dev/null
+++ b/apps/web/test/vite-dev-proxy.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from 'vitest';
+
+import { createLocalDevProxy } from '../vite.config.ts';
+
+describe('local Vite development proxy', () => {
+ it('defaults API traffic to the loopback API and covers all API prefixes', () => {
+ const proxy = createLocalDevProxy();
+
+ expect(Object.keys(proxy)).toEqual(['/v1', '/v3', '/health']);
+ expect(proxy['/v1']).toMatchObject({
+ target: 'http://127.0.0.1:3000',
+ changeOrigin: false,
+ });
+ expect(proxy['/v3']).toMatchObject({
+ target: 'http://127.0.0.1:3000',
+ changeOrigin: false,
+ });
+ expect(proxy['/health']).toMatchObject({
+ target: 'http://127.0.0.1:3000',
+ changeOrigin: false,
+ });
+ });
+
+ it('accepts only explicit loopback HTTP targets', () => {
+ expect(createLocalDevProxy('http://localhost:3010')['/v1']?.target).toBe(
+ 'http://localhost:3010',
+ );
+ expect(() => createLocalDevProxy('https://api.example.com')).toThrow('loopback HTTP');
+ expect(() => createLocalDevProxy('http://192.168.1.20:3000')).toThrow('loopback HTTP');
+ expect(() => createLocalDevProxy('http://127.0.0.1:3000/private')).toThrow('loopback HTTP');
+ });
+});
diff --git a/apps/web/test/workspace-agent-store.test.ts b/apps/web/test/workspace-agent-store.test.ts
index e632e269..35d2bf78 100644
--- a/apps/web/test/workspace-agent-store.test.ts
+++ b/apps/web/test/workspace-agent-store.test.ts
@@ -16,4 +16,58 @@ describe('workspace agent store [WEB-024, DDA-031]', () => {
expect(workspaceAgentStore.getActiveConversation()).toEqual(conversation);
workspaceAgentStore.setActiveConversation(undefined);
});
+
+ it('switches only among supplied authorized conversation summaries', () => {
+ const conversations = [
+ {
+ conversationId: 'conversation-sales',
+ title: 'Revenue review',
+ datasetLabel: 'Sales',
+ datasetVersionLabel: 'version 8',
+ },
+ {
+ conversationId: 'conversation-orders',
+ title: 'Order anomalies',
+ datasetLabel: 'Orders',
+ datasetVersionLabel: 'version 3',
+ },
+ ];
+
+ workspaceAgentStore.setConversations(conversations);
+ workspaceAgentStore.selectConversation('conversation-orders');
+ expect(workspaceAgentStore.getActiveConversation()?.conversationId).toBe('conversation-orders');
+
+ workspaceAgentStore.selectConversation('conversation-hidden');
+ expect(workspaceAgentStore.getActiveConversation()?.conversationId).toBe('conversation-orders');
+ workspaceAgentStore.setActiveConversation(undefined);
+ });
+
+ it('keeps loaded messages when a later summary-only replacement omits them', () => {
+ const loaded = {
+ conversationId: 'conversation-sales',
+ title: 'Revenue review',
+ datasetLabel: 'Sales',
+ datasetVersionLabel: 'version 8',
+ messages: [
+ {
+ messageId: 'message-1',
+ role: 'USER' as const,
+ text: 'Show regional revenue',
+ },
+ ],
+ };
+
+ workspaceAgentStore.setConversations([loaded]);
+ workspaceAgentStore.setConversations([
+ {
+ conversationId: 'conversation-sales',
+ title: 'Revenue review',
+ datasetLabel: 'Sales',
+ datasetVersionLabel: 'version 8',
+ },
+ ]);
+
+ expect(workspaceAgentStore.getActiveConversation()?.messages).toEqual(loaded.messages);
+ workspaceAgentStore.setActiveConversation(undefined);
+ });
});
diff --git a/apps/web/test/workspace-settings-route.test.tsx b/apps/web/test/workspace-settings-route.test.tsx
index c40a30e5..d9de9ba8 100644
--- a/apps/web/test/workspace-settings-route.test.tsx
+++ b/apps/web/test/workspace-settings-route.test.tsx
@@ -1,7 +1,9 @@
import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';
import { ApplicationBoundary, createAppRouter } from '../src/app/app.tsx';
+import { WorkspaceSettingsRoutePage } from '../src/features/settings/workspace-settings-page.tsx';
describe('workspace settings route [WEB-019]', () => {
it('renders the real settings surface and truthful API-unavailable state', async () => {
@@ -12,4 +14,25 @@ describe('workspace settings route [WEB-019]', () => {
expect(screen.queryByText('This area is not available yet')).toBeNull();
expect(await screen.findByText('Workspace settings could not load.')).toBeTruthy();
});
+
+ it('shows a complete owner settings workspace in explicit local demo mode', async () => {
+ const user = userEvent.setup();
+ render();
+
+ expect(screen.getByText('Mai Quỳnh')).toBeTruthy();
+ expect(screen.getByText('Chủ sở hữu')).toBeTruthy();
+ expect(
+ (screen.getByRole('combobox', { name: 'Quyền trợ lý của Mai Quỳnh' }) as HTMLSelectElement)
+ .value,
+ ).toBe('APPLY_CONFIRMED_CHANGES');
+ expect(screen.queryByText('Không thể tải cài đặt không gian làm việc.')).toBeNull();
+ await user.selectOptions(
+ screen.getByRole('combobox', { name: 'Quyền trợ lý của Mai Quỳnh' }),
+ 'ANALYZE',
+ );
+ expect(
+ (screen.getByRole('combobox', { name: 'Quyền trợ lý của Mai Quỳnh' }) as HTMLSelectElement)
+ .value,
+ ).toBe('ANALYZE');
+ });
});
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index c21f1e3c..27ada610 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -4,9 +4,60 @@ import { defineConfig } from 'vite';
import { WEB_SECURITY_HEADERS } from './security-headers.ts';
-export default defineConfig({
+const DEFAULT_LOCAL_API_TARGET = 'http://127.0.0.1:3000';
+const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']);
+
+function validateLocalApiTarget(rawTarget: string): string {
+ let target: URL;
+ try {
+ target = new URL(rawTarget);
+ } catch {
+ throw new Error('Vite API proxy target must be loopback HTTP');
+ }
+
+ if (
+ target.protocol !== 'http:' ||
+ !LOOPBACK_HOSTS.has(target.hostname) ||
+ target.username !== '' ||
+ target.password !== '' ||
+ target.pathname !== '/' ||
+ target.search !== '' ||
+ target.hash !== ''
+ ) {
+ throw new Error('Vite API proxy target must be loopback HTTP');
+ }
+
+ return target.toString().replace(/\/$/, '');
+}
+
+export function createLocalDevProxy(
+ rawTarget = process.env['VITE_DATABREEZE_API_PROXY_TARGET'] ?? DEFAULT_LOCAL_API_TARGET,
+) {
+ const target = validateLocalApiTarget(rawTarget);
+ return Object.fromEntries(
+ ['/v1', '/v3', '/health'].map((pathPrefix) => [
+ pathPrefix,
+ {
+ target,
+ changeOrigin: false,
+ },
+ ]),
+ );
+}
+
+export default defineConfig(({ command }) => ({
plugins: [react(), tailwindcss()],
+ ...(command === 'serve'
+ ? {
+ server: {
+ host: '127.0.0.1',
+ port: 5173,
+ strictPort: true,
+ proxy: createLocalDevProxy(),
+ },
+ }
+ : {}),
preview: {
headers: WEB_SECURITY_HEADERS,
},
-});
+}));
diff --git a/docs/architecture/README.md b/docs/architecture/README.md
index 7fe9a5ef..1c5e555d 100644
--- a/docs/architecture/README.md
+++ b/docs/architecture/README.md
@@ -14,5 +14,6 @@
| [Extensibility](extensibility.md) | Contracts for modules, processors, rules, connectors, imports, and exports |
| [Performance and reliability](performance-and-reliability.md) | Reference profiles, budgets, failure domains, recovery, and capacity |
| [Testing and delivery](testing-and-delivery.md) | Contract, cross-platform, security, performance, and release verification |
+| [Local development and Lightsail pilot](local-and-pilot-development.md) | Database-backed HMR, built local validation, low-cost deployment, and CI/CD flow |
Architecture documents define system-wide constraints. When a feature needs a stricter rule, its normative specification may tighten the constraint but may not silently weaken or bypass it.
diff --git a/docs/architecture/local-and-pilot-development.md b/docs/architecture/local-and-pilot-development.md
new file mode 100644
index 00000000..f3bd8a80
--- /dev/null
+++ b/docs/architecture/local-and-pilot-development.md
@@ -0,0 +1,135 @@
+# Local Development and Lightsail Pilot
+
+**Status:** Implemented development/deployment guidance
+**Version:** 1.0
+
+This document explains the two supported ways to run DataBreeze: the daily
+host-watcher loop for fast frontend work, and the low-cost single-server
+Lightsail pilot. They share the same API boundaries and persistence contracts,
+but they have different runtime goals.
+
+## Daily local development
+
+Use three terminals from the repository root:
+
+```powershell
+corepack pnpm dev:infra
+corepack pnpm dev:api
+corepack pnpm dev:web
+```
+
+The dependency stack runs in Docker. The Web and API watchers run on the host
+so source changes are visible immediately.
+
+```mermaid
+flowchart LR
+ browser["Browser\nhttp://127.0.0.1:5173"] --> vite["Vite Web\nHMR"]
+ vite --> api["Watched NestJS API\nhttp://127.0.0.1:3000"]
+ api --> postgres[("PostgreSQL\nDocker")]
+ api --> redis[("Redis\nDocker")]
+ api --> mailpit[("Mailpit\nDocker OTP")]
+ api --> minio[("MinIO\nDocker objects")]
+```
+
+`dev:api` uses the local database-backed composition, generates Prisma client
+code, applies migrations, and watches the API TypeScript build. `dev:web`
+uses Vite HMR and proxies `/v1`, `/v3`, and `/health` to the watched API.
+Therefore registration, OTP verification, sign-in, refresh, logout, and
+durable data changes use the real local backend and database rather than an
+in-memory mock.
+
+Open the HMR application at:
+
+```text
+http://127.0.0.1:5173/vi-VN/sign-in
+```
+
+The loopback HMR profile is the only place where HTTP development cookies are
+allowed. The exception is restricted to loopback origins and does not weaken
+the built or deployed HTTPS profiles.
+
+## Built local validation
+
+For an image-based, production-shaped local run, use:
+
+```powershell
+corepack pnpm local:services app-start
+```
+
+That profile starts a one-shot migration, API and Web containers, Caddy, and
+the same PostgreSQL, Redis, MinIO, Mailpit, and telemetry dependencies. Open:
+
+```text
+https://localhost:8443
+```
+
+This endpoint is HTTPS and serves built assets; it is intentionally not the
+hot-reload endpoint. Opening `http://localhost:8443` sends plain HTTP to an
+HTTPS listener and produces the expected protocol error.
+
+## Lightsail pilot
+
+The budget deployment is one Ubuntu Lightsail instance with a static IPv4 and
+Docker Compose:
+
+```mermaid
+flowchart LR
+ users["Users"] --> dns["DNS + static IPv4"]
+ dns --> caddy["Caddy\nHTTPS :443 / HTTP :80"]
+ caddy --> web["Web container"]
+ caddy --> api["API container"]
+ api --> migration["One-shot Prisma migration"]
+ api --> postgres[("PostgreSQL")]
+ api --> redis[("Redis")]
+ api --> minio[("MinIO")]
+ api --> mailpit[("Mailpit for pilot OTP")]
+```
+
+The instance runs Caddy, Web, API, the migration job, PostgreSQL, Redis,
+MinIO, and Mailpit. PostgreSQL, Redis, and MinIO are not publicly exposed;
+only the web ports are public. Mailpit is available through an SSH tunnel for
+owner testing.
+
+This is intentionally a low-cost validation pilot, not a highly available
+production architecture. A single instance is a single failure domain. Before
+real customers use it, create and verify backups of PostgreSQL and MinIO,
+restrict SSH, use a real domain and TLS certificate, and decide whether Mailpit
+must be replaced with a transactional email provider.
+
+## CI/CD flow
+
+The Lightsail workflow is:
+
+```mermaid
+flowchart LR
+ commit["Push to main"] --> checks["GitHub Actions\ncontracts, tests, builds"]
+ checks --> images["Immutable API, migration, and Web images"]
+ images --> registry["GHCR"]
+ registry --> deploy["Protected pilot deploy"]
+ deploy --> migrate["Run migration first"]
+ migrate --> rollout["Start API/Web\nhealth check"]
+ rollout --> rollback["Keep previous release\nfor rollback"]
+```
+
+Pull requests validate without connecting to the server. A protected push to
+`main` publishes immutable image digests, deploys them over restricted SSH,
+runs the migration before the API/Web services, checks `/health/ready`, and
+retains the previous release manifest for rollback. Secrets stay on the
+server/GitHub protected environment and are never put in the Web bundle.
+
+## What is intentionally different
+
+| Concern | Local HMR | Lightsail pilot |
+|---|---|---|
+| Web | Vite host watcher | Built Web container |
+| API | Host TypeScript watcher | API container |
+| Database | Docker PostgreSQL | PostgreSQL on the instance |
+| Email | Mailpit | Mailpit or approved provider |
+| Object storage | Local MinIO | MinIO on the instance |
+| Browser endpoint | Loopback HTTP for HMR | Caddy HTTPS |
+| Deployment | Local commands | GitHub Actions + protected SSH |
+| Availability | Developer machine | One Lightsail failure domain |
+
+Advanced cloud-worker execution, external provider integrations, and other
+features without an approved local authority remain fail-closed. They must not
+be represented as successful merely because the UI is running.
diff --git a/docs/development/README.md b/docs/development/README.md
index abb037a2..80409dc8 100644
--- a/docs/development/README.md
+++ b/docs/development/README.md
@@ -22,6 +22,27 @@ Android companion, API, and Python engine.
The local stack uses synthetic data only. PostgreSQL, Redis, MinIO, Mailpit,
and OpenTelemetry volumes are disposable and must never be treated as a backup.
+## Fast Web development (Vite HMR)
+
+Use the normal split workflow when iterating on the frontend. Docker owns the
+disposable infrastructure; the watched API and Vite Web server stay on the
+host so source edits are reflected immediately:
+
+```text
+Terminal A: corepack pnpm dev:infra
+Terminal B: corepack pnpm dev:api
+Terminal C: corepack pnpm dev:web
+Browser: http://127.0.0.1:5173/vi-VN/workspace
+Mailpit: http://127.0.0.1:8025
+```
+
+Vite proxies `/v1`, `/v3`, and `/health` to
+`http://127.0.0.1:3000`. The watched API uses Docker PostgreSQL through
+`DATABASE_URL`, with Prisma migrations applied on start; Redis, MinIO, and
+Mailpit remain available for adapters and local verification. The
+pilot/production `https://localhost:8443` URL serves a built bundle and is
+intentionally not a hot-reload development URL.
+
## Change workflow
- Read `docs/README.md`, the applicable specification, child plan, and ADR
diff --git a/docs/plans/408-local-usable-vertical-slice.md b/docs/plans/408-local-usable-vertical-slice.md
index 0f4c731e..450a04c0 100644
--- a/docs/plans/408-local-usable-vertical-slice.md
+++ b/docs/plans/408-local-usable-vertical-slice.md
@@ -13,7 +13,7 @@ The slice must preserve production security. It must not weaken production start
1. Add failing tests for a local-only runtime composition using durable Prisma IAM/session/bootstrap state, Redis admission control, Mailpit delivery, safe local keys, and the existing MinIO endpoints. Compose IAM-022 activation with a narrow DSO-008 initial-workspace-policy transaction participant that creates the server-owned HYBRID revision-1 policy/current pointer and supplies only its content-safe binding to IAM. Exact replay must reuse the binding; mismatched or partial state fails closed. Production composition must remain unchanged and fail closed.
2. Add a closed unpublished v4 `/v1/me/bootstrap` contract, fixtures, generated TS/Python/Kotlin models, and API response conformance. Web derives navigation and workspace scope from this server response.
-3. Add a same-origin HTTPS local gateway and reproducible lifecycle command that starts dependencies, applies all migrations, builds/starts API and Web, and reports bounded readiness failures.
+3. Add a same-origin HTTPS local gateway and reproducible lifecycle command that starts dependencies, applies all migrations, builds/starts API and Web, and reports bounded readiness failures. Also provide a database-backed host watcher profile: `dev:infra` owns Docker dependencies, `dev:api` runs the watched API against those durable services, and `dev:web` keeps Vite HMR while proxying to that API. The HMR profile is explicitly loopback-only and may use HTTP-only development cookies; the built local gateway remains HTTPS with Secure cookies.
4. Wire registration, OTP verification, sign-in, refresh-on-reload, logout, and protected-route redirects through the generated contracts. Browser credentials remain HttpOnly, Secure, SameSite=Lax and never enter persistent JavaScript storage.
5. Connect the first meaningful data path through local MinIO: create an authorized upload/intake, preserve server-owned tenant scope, show the durable Inbox/Data result, and expose a real starter-dashboard creation/load path after a governed dataset version exists. Remove false-success/demo behavior from the authenticated path. Features without an authoritative backend dependency expose localized unavailable or empty states.
6. Verify unit/contract/typecheck gates plus a real browser journey against the running local stack. Record the exact command, Mailpit URL, and production-only remaining gates.
diff --git a/docs/plans/409-adaptive-workspace-shell-agent-implementation.md b/docs/plans/409-adaptive-workspace-shell-agent-implementation.md
new file mode 100644
index 00000000..7f515c59
--- /dev/null
+++ b/docs/plans/409-adaptive-workspace-shell-agent-implementation.md
@@ -0,0 +1,502 @@
+# Adaptive Workspace Shell and Agent Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Status:** Approved by the product owner on 2026-08-14
+
+**Goal:** Deliver the approved premium blue Web workspace with an adaptive sidebar, a dashboard-only canvas, and one conversation-aware agent that can propose and explicitly add compatible charts.
+
+**Architecture:** Keep the existing React Router shell, generated conversation contracts, governed dashboard authoring commands, and server authorization boundaries. Add a presentation-only sidebar preference, derive authorized secondary navigation from the existing registry, synchronize conversation summaries through the shared agent store, and reuse the existing dashboard proposal/confirmation pipeline inside a richer chat surface.
+
+**Tech Stack:** React 19, React Router 7, TypeScript 5.9, TanStack Query 5, Vitest, Testing Library, Vite, existing DataBreeze design tokens and generated contracts.
+
+## Global Constraints
+
+- Requirements: WEB-002, WEB-013, WEB-014, WEB-019, WEB-020, WEB-021, WEB-022, WEB-024; DDA-015, DDA-016, DDA-020, DDA-021, DDA-022, DDA-024, DDA-026, DDA-043, DDA-055, DDA-056.
+- Vietnamese remains the default locale and English remains complete.
+- Cobalt `#075DE8`, deep blue `#102A63`, and pale blue-gray `#F4F7FC` are the primary workspace colors.
+- Do not add decorative gradients, emoji icons, arbitrary generated UI, SQL, JavaScript, or authoritative numeric values.
+- The client never treats navigation filtering, stored preferences, or conversation presentation state as authorization.
+- Dashboard mutations continue through existing governed authoring commands and require explicit user confirmation.
+- Sidebar persistence stores only the compact/expanded presentation preference under `databreeze.sidebar.compact.v1`.
+- Preserve existing tenant isolation, generated-contract parsing, exact version context, audit, evidence, and fail-closed unavailable states.
+
+---
+
+## File Structure
+
+- `apps/web/src/components/sidebar-preference.ts` — bounded browser-only compact preference.
+- `apps/web/src/components/application-rail.tsx` — adaptive sidebar presentation and accessible controls.
+- `apps/web/src/components/shell-layout.tsx` — shell state, authorized secondary tools, and direct dashboard canvas outlet.
+- `apps/web/src/styles/workspace-shell.css` — expanded/compact/mobile shell geometry and common page rhythm.
+- `apps/web/src/features/agent/agent-store.ts` — shared authorized conversation summaries and active identity.
+- `apps/web/src/features/agent/agent-chat-shell.tsx` — reusable conversation switcher, message list, context, and composer.
+- `apps/web/src/features/agent/floating-agent-panel.tsx` — Data route composition around the shared chat shell.
+- `apps/web/src/features/analysis/analysis-route-page.tsx` — authoritative conversation synchronization.
+- `apps/web/src/features/dashboards/dashboard-agent-panel.tsx` — dashboard chat plus chart proposal selection.
+- `apps/web/src/features/dashboards/dashboard-page.tsx` — chat presentation state and governed chart insertion.
+- Existing route CSS files — consistent blue canvas, page hierarchy, focus, responsive, and forced-color behavior.
+
+---
+
+### Task 1: Adaptive sidebar preference and accessible navigation
+
+**Files:**
+- Create: `apps/web/src/components/sidebar-preference.ts`
+- Modify: `apps/web/src/components/application-rail.tsx`
+- Modify: `apps/web/src/styles/workspace-shell.css`
+- Test: `apps/web/test/application-rail.test.tsx`
+- Test: `apps/web/test/navigation-access.test.tsx`
+
+**Interfaces:**
+- Produces: `readSidebarCompactPreference(): boolean | undefined`
+- Produces: `writeSidebarCompactPreference(compact: boolean): void`
+- Produces: `ApplicationRailProperties.collapsed`, `onCollapsedChange`, and `secondaryItems`
+- Consumes: `UdwPrimaryNavItemV1`, `NavigationItem`, and existing route labels.
+
+- [ ] **Step 1: Write failing sidebar tests**
+
+```tsx
+it('starts expanded, collapses to icon-only navigation, and remembers the preference', async () => {
+ const user = userEvent.setup();
+ renderDashboard();
+ expect(screen.getByText('Bảng điều khiển')).toBeVisible();
+ await user.click(screen.getByRole('button', { name: 'Thu gọn thanh bên' }));
+ expect(screen.getByRole('navigation', { name: 'Điều hướng chính' })).toHaveAttribute(
+ 'data-collapsed',
+ 'true',
+ );
+ expect(localStorage.getItem('databreeze.sidebar.compact.v1')).toBe('true');
+ expect(screen.getByRole('link', { name: 'Bảng điều khiển' })).toHaveAttribute(
+ 'title',
+ 'Bảng điều khiển',
+ );
+});
+
+it('renders authorized Inbox, Reviews, and Settings as secondary tools', async () => {
+ renderDashboard();
+ expect(await screen.findByRole('link', { name: 'Hộp thư đến' })).toBeVisible();
+ expect(screen.getByRole('link', { name: 'Cần xem xét' })).toBeVisible();
+ expect(screen.getByRole('link', { name: 'Cài đặt' })).toBeVisible();
+});
+```
+
+- [ ] **Step 2: Run the sidebar tests and confirm RED**
+
+Run: `corepack pnpm --filter @databreeze/web exec vitest run test/application-rail.test.tsx test/navigation-access.test.tsx`
+
+Expected: FAIL because no collapse control, preference helper, or secondary group exists.
+
+- [ ] **Step 3: Implement the bounded preference helper**
+
+```ts
+const SIDEBAR_COMPACT_KEY = 'databreeze.sidebar.compact.v1';
+
+export function readSidebarCompactPreference(): boolean | undefined {
+ const value = globalThis.localStorage?.getItem(SIDEBAR_COMPACT_KEY);
+ return value === 'true' ? true : value === 'false' ? false : undefined;
+}
+
+export function writeSidebarCompactPreference(compact: boolean): void {
+ globalThis.localStorage?.setItem(SIDEBAR_COMPACT_KEY, String(compact));
+}
+```
+
+- [ ] **Step 4: Implement expanded, compact, and mobile sidebar rendering**
+
+Add a real toggle with `aria-expanded={!collapsed}`, localized expand/collapse labels, the full wordmark when expanded, the mark when compact, grouped primary and secondary lists, icon-only `title` attributes, route-selection close on mobile, and existing Escape handling. Secondary items use the existing registered paths and authorization-filtered list supplied by `ShellLayout`.
+
+- [ ] **Step 5: Add shell geometry and accessibility CSS**
+
+```css
+.app-shell { --sidebar-width: 248px; grid-template-columns: var(--sidebar-width) minmax(0, 1fr); }
+.app-shell[data-sidebar-collapsed='true'] { --sidebar-width: 72px; }
+.application-rail__label { opacity: 1; white-space: nowrap; }
+.application-rail[data-collapsed='true'] .application-rail__label,
+.application-rail[data-collapsed='true'] .application-rail__group-label { position: absolute; inline-size: 1px; block-size: 1px; overflow: hidden; clip: rect(0 0 0 0); }
+@media (prefers-reduced-motion: reduce) { .app-shell, .application-rail { transition: none; } }
+```
+
+- [ ] **Step 6: Run sidebar tests and confirm GREEN**
+
+Run: `corepack pnpm --filter @databreeze/web exec vitest run test/application-rail.test.tsx test/navigation-access.test.tsx`
+
+Expected: PASS with exactly three primary destinations and authorized secondary tools.
+
+- [ ] **Step 7: Commit the sidebar slice**
+
+```bash
+git add apps/web/src/components/sidebar-preference.ts apps/web/src/components/application-rail.tsx apps/web/src/styles/workspace-shell.css apps/web/test/application-rail.test.tsx apps/web/test/navigation-access.test.tsx
+git commit -m "feat(web): add adaptive workspace sidebar"
+```
+
+---
+
+### Task 2: Shell integration and dashboard-only canvas
+
+**Files:**
+- Modify: `apps/web/src/components/shell-layout.tsx`
+- Modify: `apps/web/src/styles/workspace-shell.css`
+- Test: `apps/web/test/application-rail.test.tsx`
+- Test: `apps/web/test/dashboard-workspace.test.tsx`
+
+**Interfaces:**
+- Consumes: `readSidebarCompactPreference`, `writeSidebarCompactPreference`, `filterNavigationItems`.
+- Produces: direct dashboard outlet with no `DashboardWorkspace` history wrapper.
+
+- [ ] **Step 1: Write failing shell tests**
+
+```tsx
+it('renders dashboard directly on the canvas without analysis history controls', async () => {
+ renderDashboard();
+ expect(await screen.findByRole('heading', { name: 'Bức tranh kinh doanh' })).toBeVisible();
+ expect(screen.queryByRole('button', { name: 'Phân tích mới' })).toBeNull();
+ expect(screen.queryByRole('searchbox', { name: 'Tìm kiếm lịch sử' })).toBeNull();
+});
+
+it('uses compact sidebar by default at tablet width only when no preference exists', () => {
+ setViewport('(min-width: 768px) and (max-width: 1023px)', true);
+ renderDashboard();
+ expect(screen.getByRole('navigation', { name: 'Điều hướng chính' })).toHaveAttribute(
+ 'data-collapsed',
+ 'true',
+ );
+});
+```
+
+- [ ] **Step 2: Run the shell tests and confirm RED**
+
+Run: `corepack pnpm --filter @databreeze/web exec vitest run test/application-rail.test.tsx test/dashboard-workspace.test.tsx`
+
+Expected: FAIL because Dashboard is still wrapped by `DashboardWorkspace` and sidebar state is not shell-owned.
+
+- [ ] **Step 3: Integrate adaptive state and secondary navigation**
+
+In `ShellLayout`, initialize explicit preference first, otherwise use compact mode for 768–1023 pixels; persist only user-triggered changes. Derive the secondary list with:
+
+```ts
+const SECONDARY_KEYS = new Set(['inbox', 'reviews', 'administration']);
+const secondaryItems = filterNavigationItems(accessContext).filter((item) =>
+ SECONDARY_KEYS.has(item.key),
+);
+```
+
+Pass `collapsed`, `onCollapsedChange`, and `secondaryItems` to `ApplicationRail`, and set `data-sidebar-collapsed` on `.app-shell`.
+
+- [ ] **Step 4: Remove the dashboard history wrapper from the route**
+
+Replace the conditional `DashboardWorkspace` wrapper with a direct ``. Keep the `DashboardWorkspace` component and its focused legacy tests intact until its governed history behavior is deliberately retired elsewhere; it is simply no longer mounted inside Dashboard.
+
+- [ ] **Step 5: Make the dashboard workspace fill the canvas**
+
+Set `.main-workspace--dashboard` to a pale blue canvas with no nested history grid, min-width zero, and responsive padding. Remove dashboard-specific rail overrides that contradict the shared adaptive sidebar.
+
+- [ ] **Step 6: Run shell and dashboard tests and confirm GREEN**
+
+Run: `corepack pnpm --filter @databreeze/web exec vitest run test/application-rail.test.tsx test/dashboard-workspace.test.tsx test/dashboard-canvas.test.tsx`
+
+Expected: PASS; Dashboard contains no analysis-history controls and canvas authoring remains functional.
+
+- [ ] **Step 7: Commit the shell integration slice**
+
+```bash
+git add apps/web/src/components/shell-layout.tsx apps/web/src/styles/workspace-shell.css apps/web/test/application-rail.test.tsx apps/web/test/dashboard-workspace.test.tsx
+git commit -m "feat(web): restore dashboard-only canvas"
+```
+
+---
+
+### Task 3: Shared authorized conversation state and chat shell
+
+**Files:**
+- Modify: `apps/web/src/features/agent/agent-store.ts`
+- Create: `apps/web/src/features/agent/agent-chat-shell.tsx`
+- Modify: `apps/web/src/features/analysis/analysis-route-page.tsx`
+- Modify: `apps/web/src/features/agent/floating-agent-panel.tsx`
+- Modify: `apps/web/src/styles/workspace-shell.css`
+- Test: `apps/web/test/floating-agent.test.tsx`
+- Test: `apps/web/test/analysis-destination.test.tsx`
+
+**Interfaces:**
+- Produces: `AgentConversationSummaryV1`, `AgentMessagePresentationV1`, `setConversations`, `selectConversation`, and `getConversations`.
+- Produces: `AgentChatShell` props for conversations, active ID, messages, context, composer, select, create, submit, and Analysis link.
+- Consumes: authorized `DdaConversationSummary` and `DdaConversationLoadAccepted` results.
+
+- [ ] **Step 1: Write failing store and panel tests**
+
+```tsx
+it('switches only among supplied authorized conversations', async () => {
+ const user = userEvent.setup();
+ render( undefined} />);
+ await user.click(screen.getByRole('button', { name: 'Đơn hàng bất thường' }));
+ expect(store.getActiveConversation()?.conversationId).toBe('conversation-orders');
+ expect(screen.getByText('Ngữ cảnh: Tồn kho cửa hàng')).toBeVisible();
+});
+
+it('opens the selected conversation in full Analysis', () => {
+ renderPanel();
+ expect(screen.getByRole('link', { name: 'Mở trong Phân tích' })).toHaveAttribute(
+ 'href',
+ '/vi-VN/analysis?conversation=conversation-sales',
+ );
+});
+```
+
+- [ ] **Step 2: Run the focused tests and confirm RED**
+
+Run: `corepack pnpm --filter @databreeze/web exec vitest run test/floating-agent.test.tsx test/analysis-destination.test.tsx`
+
+Expected: FAIL because the store exposes only one active conversation and the compact panel has no switcher or chat shell.
+
+- [ ] **Step 3: Extend the shared store without persisting authority**
+
+```ts
+export interface AgentConversationSummaryV1 {
+ readonly conversationId: string;
+ readonly title: string;
+ readonly datasetLabel: string;
+ readonly datasetVersionLabel: string;
+}
+
+export interface AgentStoreV1 {
+ getConversations(): readonly AgentConversationSummaryV1[];
+ setConversations(items: readonly AgentConversationSummaryV1[]): void;
+ selectConversation(conversationId: string): void;
+}
+```
+
+`selectConversation` must ignore unknown IDs. Conversation summaries stay in memory and are replaced or cleared when authoritative history is unavailable.
+
+- [ ] **Step 4: Build the reusable chat shell**
+
+Render a compact conversation dropdown/list, new conversation button, authorized context summary, scrollable user/assistant messages, a labeled textarea, loading/denial/unavailable states, send button, and Analysis deep link. Keep focus trap/return behavior in the containing panel.
+
+- [ ] **Step 5: Synchronize Analysis history into the store**
+
+When the authorized history query succeeds, map all authorized summaries into `setConversations`; when it fails or becomes empty, clear them. Selecting a conversation from Analysis or the compact agent uses the same active ID, while URL search parameters remain the full Analysis source of navigation truth.
+
+- [ ] **Step 6: Compose Data floating agent with the chat shell**
+
+Preserve the current Data context and fail-closed behavior. Do not fabricate messages or enable send when no authorized conversation/send callback exists. The new-conversation action links to or opens Analysis unless a real creation callback is supplied.
+
+- [ ] **Step 7: Run focused tests and confirm GREEN**
+
+Run: `corepack pnpm --filter @databreeze/web exec vitest run test/floating-agent.test.tsx test/analysis-destination.test.tsx`
+
+Expected: PASS for switching, context restoration, Analysis link, unavailable history, and no fabricated message behavior.
+
+- [ ] **Step 8: Commit shared agent foundations**
+
+```bash
+git add apps/web/src/features/agent apps/web/src/features/analysis/analysis-route-page.tsx apps/web/src/styles/workspace-shell.css apps/web/test/floating-agent.test.tsx apps/web/test/analysis-destination.test.tsx
+git commit -m "feat(web): share authorized agent conversations"
+```
+
+---
+
+### Task 4: Dashboard agent chat and explicit chart insertion
+
+**Files:**
+- Modify: `apps/web/src/features/dashboards/dashboard-agent-panel.tsx`
+- Modify: `apps/web/src/features/dashboards/dashboard-page.tsx`
+- Modify: `apps/web/src/features/dashboards/dashboard-page.css`
+- Test: `apps/web/test/dashboard-agent-panel.test.tsx`
+- Test: `apps/web/test/chart-proposal-picker.test.tsx`
+- Test: `apps/web/test/dashboard-canvas.test.tsx`
+
+**Interfaces:**
+- Consumes: `AgentChatShell`, `workspaceAgentStore`, existing `askForChart`, `acceptCharts`, and `DashboardAuthoringCommandQueueV1`.
+- Produces: a Notion-like chat panel whose proposal confirmation calls the existing governed authoring path exactly once.
+
+- [ ] **Step 1: Write failing dashboard agent tests**
+
+```tsx
+it('shows conversation history and keeps proposals inside the assistant turn', async () => {
+ renderAgentPanel({ conversations, messages, proposalOptions });
+ expect(screen.getByRole('button', { name: 'Bức tranh kinh doanh' })).toBeVisible();
+ expect(screen.getByText('Cho tôi xem doanh thu theo khu vực')).toBeVisible();
+ expect(screen.getByRole('checkbox', { name: 'So sánh theo nhóm' })).toBeVisible();
+});
+
+it('names the exact mutation and does not add a chart before confirmation', async () => {
+ const user = userEvent.setup();
+ renderDashboard();
+ await openProposalAndSelectTwo(user);
+ expect(screen.getByRole('button', { name: 'Thêm 2 biểu đồ vào canvas' })).toBeVisible();
+ expect(screen.queryByText('Biểu đồ được đề xuất')).toBeNull();
+ await user.click(screen.getByRole('button', { name: 'Thêm 2 biểu đồ vào canvas' }));
+ expect(await screen.findByText('Biểu đồ được đề xuất')).toBeVisible();
+});
+```
+
+- [ ] **Step 2: Run dashboard agent tests and confirm RED**
+
+Run: `corepack pnpm --filter @databreeze/web exec vitest run test/dashboard-agent-panel.test.tsx test/chart-proposal-picker.test.tsx test/dashboard-canvas.test.tsx`
+
+Expected: FAIL because the current panel is form-like and lacks conversation/message composition.
+
+- [ ] **Step 3: Compose the panel around `AgentChatShell`**
+
+Keep the proposal picker within the latest assistant response. Add exact selected-count copy:
+
+```ts
+const confirmLabel = locale === 'vi-VN'
+ ? `Thêm ${selectedOptionIds.length} biểu đồ vào canvas`
+ : `Add ${selectedOptionIds.length} charts to canvas`;
+```
+
+Do not call `onConfirmProposal` from selection or agent response rendering.
+
+- [ ] **Step 4: Bind dashboard chat presentation state**
+
+Append the submitted question as a user presentation message, call existing `askForChart`, then append a concise assistant explanation carrying proposal cards. In demo mode, use only the existing deterministic demo proposal values. In live mode, show only returned governed results and clear/disable mutation controls on unavailable or rejected responses.
+
+- [ ] **Step 5: Preserve governed insertion and focus behavior**
+
+Call existing `acceptCharts(selectedOptionIds)` only from the explicit confirm button. After success, set the canvas focus target to the first inserted stable widget ID and announce saving/saved/conflict status through the existing authoring state.
+
+- [ ] **Step 6: Style the panel as a responsive AI chat**
+
+Use a 420-pixel wide desktop panel, right sheet at medium widths, and full-height bottom sheet on narrow screens. Use white/blue-gray surfaces, restrained borders, distinct user/assistant message alignment, visible context, and no gradients.
+
+- [ ] **Step 7: Run dashboard agent tests and confirm GREEN**
+
+Run: `corepack pnpm --filter @databreeze/web exec vitest run test/dashboard-agent-panel.test.tsx test/chart-proposal-picker.test.tsx test/dashboard-canvas.test.tsx`
+
+Expected: PASS for conversation switching, message submission, proposal selection, explicit confirmation, and no silent mutation.
+
+- [ ] **Step 8: Commit dashboard agent integration**
+
+```bash
+git add apps/web/src/features/dashboards/dashboard-agent-panel.tsx apps/web/src/features/dashboards/dashboard-page.tsx apps/web/src/features/dashboards/dashboard-page.css apps/web/test/dashboard-agent-panel.test.tsx apps/web/test/chart-proposal-picker.test.tsx apps/web/test/dashboard-canvas.test.tsx
+git commit -m "feat(web): add conversational dashboard agent"
+```
+
+---
+
+### Task 5: Premium organization for Data, Reviews, Inbox, Analysis, and Settings
+
+**Files:**
+- Modify: `apps/web/src/features/analysis/analysis-page.css`
+- Modify: `apps/web/src/features/data/data-workspace.css`
+- Modify: `apps/web/src/styles/data-intake.css`
+- Modify: `apps/web/src/features/settings/workspace-settings.css`
+- Modify only where needed for semantic hierarchy: corresponding route page `.tsx` files.
+- Test: `apps/web/test/analysis-destination.test.tsx`
+- Test: `apps/web/test/data-pipeline-route.test.tsx`
+- Test: `apps/web/test/workspace-settings-route.test.tsx`
+- Test: route tests for Inbox/Data already present under `apps/web/test/`.
+
+**Interfaces:**
+- Consumes: shared sidebar and shell canvas tokens.
+- Produces: consistent title, description, action, primary work surface, and evidence hierarchy across all first-party routes.
+
+- [ ] **Step 1: Add failing hierarchy and state tests**
+
+For each route, assert one `h1`, a concise explanatory description, visible primary work surface, and distinct unavailable/empty state. Assert that Reviews keeps the enabled CSV/XLSX upload path when local/demo intake is configured and that Settings keeps member/session controls operational.
+
+- [ ] **Step 2: Run the focused page suites and confirm RED where hierarchy is missing**
+
+Run: `corepack pnpm --filter @databreeze/web exec vitest run test/analysis-destination.test.tsx test/data-pipeline-route.test.tsx test/workspace-settings-route.test.tsx test/data-workspace.test.tsx`
+
+Expected: only pages missing the approved semantic hierarchy fail.
+
+- [ ] **Step 3: Normalize page composition and visual tokens**
+
+Use one compact heading block, state/action row, white primary workspace, and quieter supporting evidence. Remove decorative over-cardification, green primary actions, mixed radius scales, and unnecessary pill wrappers. Keep success green and warnings amber only for semantic statuses; primary actions and selection remain cobalt.
+
+- [ ] **Step 4: Verify responsive and accessibility rules in CSS**
+
+Add 200-percent zoom-safe wrapping, focus-visible rings, forced-color borders, reduced motion, and mobile stacking without horizontal clipping.
+
+- [ ] **Step 5: Run focused page suites and confirm GREEN**
+
+Run: `corepack pnpm --filter @databreeze/web exec vitest run test/analysis-destination.test.tsx test/data-pipeline-route.test.tsx test/workspace-settings-route.test.tsx test/data-workspace.test.tsx`
+
+Expected: PASS with live controls preserved and unavailable states not presented as empty data.
+
+- [ ] **Step 6: Commit page organization**
+
+```bash
+git add apps/web/src/features/analysis apps/web/src/features/data apps/web/src/features/data-intake apps/web/src/features/settings apps/web/src/styles/data-intake.css apps/web/test
+git commit -m "feat(web): unify premium workspace pages"
+```
+
+---
+
+### Task 6: Full verification and local runtime proof
+
+**Files:**
+- Modify only for defects revealed by verification.
+- Verify: `apps/web` and local Web container.
+
+**Interfaces:**
+- Consumes: all prior tasks.
+- Produces: tested local Web behavior and evidence for handoff.
+
+- [ ] **Step 1: Run the complete Web unit suite**
+
+Run: `corepack pnpm --filter @databreeze/web test`
+
+Expected: every Vitest file passes with zero failures.
+
+- [ ] **Step 2: Run TypeScript and production build**
+
+Run: `corepack pnpm --filter @databreeze/web typecheck`
+
+Expected: exit 0.
+
+Run: `corepack pnpm --filter @databreeze/web build`
+
+Expected: exit 0 and the existing 256,000-byte gzip initial JavaScript budget passes.
+
+- [ ] **Step 3: Run formatting and whitespace checks**
+
+Run: `corepack pnpm exec prettier --check apps/web/src apps/web/test docs/plans/409-adaptive-workspace-shell-agent-implementation.md`
+
+Expected: exit 0.
+
+Run: `git diff --check`
+
+Expected: no whitespace errors in the owned slice.
+
+- [ ] **Step 4: Rebuild and restart only the local Web service**
+
+Run: `docker compose --env-file infrastructure/local/.env.local -f infrastructure/local/compose.yml --profile app build web`
+
+Expected: exit 0.
+
+Run: `docker compose --env-file infrastructure/local/.env.local -f infrastructure/local/compose.yml --profile app up -d --no-deps web gateway`
+
+Expected: Web and gateway become healthy without replacing PostgreSQL data.
+
+- [ ] **Step 5: Verify local routes and API routing**
+
+Run: `Invoke-WebRequest -SkipCertificateCheck https://localhost:8443/vi-VN/dashboards | Select-Object StatusCode,Headers`
+
+Expected: 200 `text/html`.
+
+Run: `try { Invoke-WebRequest -SkipCertificateCheck https://localhost:8443/v1/me/bootstrap } catch { $_.Exception.Response.StatusCode.value__ }`
+
+Expected: 401 for a signed-out browser, proving `/v1` is API-routed rather than rewritten to the SPA.
+
+- [ ] **Step 6: Perform signed-in browser acceptance**
+
+At desktop and narrow widths, verify: wordmark-expanded sidebar; compact icon mode; preference across reload; exactly three primary destinations; authorized secondary tools; no Dashboard history panel or `Phân tích mới`; pale-blue full canvas; agent conversation switching; contextual messages; explicit chart confirmation; inserted widget focus; Data/Reviews/Settings page organization; login/logout and route protection.
+
+- [ ] **Step 7: Commit verified implementation**
+
+```bash
+git add docs/plans/409-adaptive-workspace-shell-agent-implementation.md apps/web
+git commit -m "feat(web): deliver adaptive premium workspace"
+```
+
+---
+
+## Self-Review
+
+- Spec coverage: Tasks 1–2 cover adaptive navigation and dashboard-only canvas; Tasks 3–4 cover shared conversations, chat, and explicit chart insertion; Task 5 covers all named secondary pages; Task 6 covers accessibility, bundle, local runtime, and signed-in acceptance.
+- Authorization: navigation is only a client hint; API and generated-contract boundaries remain authoritative.
+- Mutation safety: chart selection does not mutate; the exact confirm action invokes the existing governed authoring command path.
+- Type consistency: shared store and chat shell names are defined in Task 3 and consumed unchanged in Task 4.
+- No backend values, prior messages, tenant scope, or success state are fabricated when live services are unavailable.
diff --git a/docs/plans/410-local-hmr-development-workflow.md b/docs/plans/410-local-hmr-development-workflow.md
new file mode 100644
index 00000000..89ef78b2
--- /dev/null
+++ b/docs/plans/410-local-hmr-development-workflow.md
@@ -0,0 +1,85 @@
+# Local HMR Development Workflow Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make normal local Web development hot-reloadable while Docker owns durable infrastructure and the API can run in a watched host process.
+
+**Architecture:** `infrastructure/local/compose.yml` remains the infrastructure-only profile for PostgreSQL, Redis, MinIO, Mailpit, and OpenTelemetry. A new Vite development proxy maps `/v1`, `/v3`, and `/health` to the host API; `pnpm dev:stack` starts infrastructure and prints the two host-process commands, while `pnpm dev:web` runs Vite with React Refresh/HMR from `apps/web` so workspace dependency links resolve correctly. The production/pilot Caddy/Web images are unchanged and remain used only for preview/deployment validation.
+
+**Tech Stack:** Docker Compose v2, Node 24, pnpm 11, NestJS/Fastify API, Vite 8, React Refresh, Vitest/Node static tests.
+
+## Global Constraints
+
+- Keep PostgreSQL, Redis, MinIO, Mailpit, and OpenTelemetry private to localhost and preserve named-volume lifecycle safety.
+- Do not put credentials, bearer tokens, tenant data, or OpenAI keys in Vite configuration or browser persistence.
+- Development may use loopback HTTP only; production/pilot HTTPS and fail-closed runtime profiles remain unchanged.
+- Keep Vietnamese as the default locale and do not change public contracts for a dev-server convenience.
+- Web proxy targets are explicit loopback paths only; no arbitrary browser-controlled proxy destination is accepted.
+- The API host process uses the repository's local database-backed composition. Docker PostgreSQL is required, `DATABASE_URL` is configured, and Prisma migrations are applied before the watched API starts. Redis, MinIO, and Mailpit remain available for adapters and local verification.
+
+---
+
+### Task 1: Vite development proxy and explicit API URL behavior
+
+**Files:**
+- Modify: `apps/web/vite.config.ts`
+- Modify: `apps/web/src/features/inbox/inbox-api.ts` only if the shared API URL helper is extracted
+- Test: `apps/web/test/vite-dev-proxy.test.ts`
+
+**Interfaces:**
+- Consumes: `VITE_DATABREEZE_API_PROXY_TARGET` with default `http://127.0.0.1:3000`.
+- Produces: Vite dev requests for `/v1/*`, `/v3/*`, and `/health/*` proxied to the API without changing production builds or generated contracts.
+
+- [x] **Step 1: Write the failing test** asserting the Vite config exposes a dev-only proxy for the three API path prefixes, defaults to loopback API port 3000, and never uses a user-controlled full URL as a proxy destination.
+- [x] **Step 2: Run the focused test** with `corepack pnpm --filter @databreeze/web exec vitest run test/vite-dev-proxy.test.ts`; confirm it fails because the proxy is absent.
+- [x] **Step 3: Implement the proxy** with a validated loopback-only target parser and `changeOrigin: false`; keep `preview` headers unchanged.
+- [x] **Step 4: Run the focused test and Web typecheck**; expect both to pass.
+
+### Task 2: Host-process development commands
+
+**Files:**
+- Modify: `package.json`
+- Create: `tools/repo-cli/src/dev-stack.mjs`
+- Modify: `apps/web/package.json` only if a clearer `dev` alias is useful
+- Test: `tools/repo-cli/test/dev-stack.test.mjs`
+- Modify: `infrastructure/local/README.md`
+
+**Interfaces:**
+- Consumes: existing `pnpm local:services` lifecycle commands, `services/api` build/start scripts, and `apps/web` Vite `dev` script.
+- Produces: `pnpm dev:infra`, `pnpm dev:api`, `pnpm dev:web`, and `pnpm dev:stack` guidance/validation with no hidden production Compose startup.
+
+- [x] **Step 1: Write failing tests** for command definitions, bounded local API environment defaults, and a `dev:stack` message that clearly separates Docker infrastructure from host API/Web processes.
+- [x] **Step 2: Run the focused Node tests** and confirm the new commands/test helper are missing.
+- [x] **Step 3: Add the commands**: `dev:infra` delegates to `local:services start`; `dev:api` runs the API watch command against `127.0.0.1` services; `dev:web` runs Vite on `127.0.0.1:5173`; `dev:stack` validates infrastructure and prints copy-pasteable terminal commands without spawning orphan processes.
+- [x] **Step 4: Run focused tests, `pnpm local:services config`, API typecheck, and Web typecheck**.
+- [x] **Step 5: Start the Web watcher from `apps/web` and add a regression test for package-local dependency resolution.**
+
+### Task 3: Developer documentation and HMR acceptance
+
+**Files:**
+- Modify: `docs/development/README.md`
+- Modify: `apps/web/README.md`
+- Test: `tools/repo-cli/test/dev-stack.test.mjs`
+
+**Interfaces:**
+- Consumes: the exact commands and proxy target from Tasks 1–2.
+- Produces: a documented two-terminal workflow and a smoke acceptance that editing a Web source file triggers Vite HMR while API requests continue to reach Docker-backed infrastructure.
+
+- [x] **Step 1: Document the workflow**: Terminal A `pnpm dev:infra`, Terminal B `pnpm dev:api`, Terminal C `pnpm dev:web`; browser URL `http://127.0.0.1:5173/vi-VN/workspace`; Mailpit `http://127.0.0.1:8025`.
+- [x] **Step 2: Add static assertions** that the documentation names the HMR URL, API proxy, Docker-only infrastructure, and warns against using the pilot/production Caddy URL for source editing.
+- [x] **Step 3: Run the documentation/static tests and Web development E2E configuration**.
+
+### Task 4: Verification
+
+**Files:**
+- Test: `apps/web/test/vite-dev-proxy.test.ts`
+- Test: `tools/repo-cli/test/dev-stack.test.mjs`
+
+- [x] **Step 1: Run the focused tests and both package typechecks.**
+- [x] **Step 2: Start Docker infrastructure and verify every health check.**
+- [x] **Step 3: Start the host API and Vite Web processes; verify `http://127.0.0.1:5173` renders and an API request reaches `http://127.0.0.1:3000`.**
+- [x] **Step 4: Edit a harmless Web source string, observe the Vite HMR update without a full rebuild, and record any remaining browser/runtime limitation.**
+
+## Self-review and known boundary
+
+This plan does not change production/pilot Docker images, Caddy TLS, database schemas, API contracts, or feature behavior. It intentionally separates fast frontend iteration from release-like container validation; release smoke remains available through the existing preview/pilot workflows.
diff --git a/docs/superpowers/specs/2026-08-14-adaptive-workspace-shell-agent-design.md b/docs/superpowers/specs/2026-08-14-adaptive-workspace-shell-agent-design.md
new file mode 100644
index 00000000..e84441ef
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-14-adaptive-workspace-shell-agent-design.md
@@ -0,0 +1,166 @@
+# Adaptive Workspace Shell and Agent Design
+
+**Status:** Approved by the product owner on 2026-08-14
+**Applies to:** DataBreeze Web workspace shell, Dashboard, Analysis, Data, Inbox, Reviews, Settings, and the shared workspace agent
+**Refines:** `2026-08-12-unified-data-workspace-experience-design.md`, `404-dashboard-workspace-redesign-design.md`, and Web tasks in plan `406`
+**Requirement links:** WEB-002, WEB-013, WEB-014, WEB-019, WEB-020, WEB-021, WEB-022, WEB-024; DDA-015, DDA-016, DDA-020, DDA-021, DDA-022, DDA-024, DDA-026, DDA-043, DDA-055, DDA-056
+
+## 1. Product outcome
+
+DataBreeze uses one calm, premium, blue workspace shell. The shell makes the product hierarchy obvious without reducing the dashboard canvas or agent to small cards. The interface must feel intentionally designed, not like a collection of unrelated generated pages.
+
+The user can:
+
+1. Expand or collapse the global sidebar.
+2. Reach the three primary destinations immediately.
+3. Reach operational workspace tools without giving them equal visual weight.
+4. Use the Dashboard as a full canvas with no analysis-history column inside it.
+5. Open one shared agent from Dashboard or Data, switch conversations, continue history, ask a question, inspect chart proposals, select compatible charts, and explicitly add them to the dashboard canvas.
+
+## 2. Adaptive global sidebar
+
+### 2.1 Desktop expanded state
+
+- The sidebar is expanded by default on a new desktop browser profile.
+- It is 248 pixels wide and contains the DataBreeze wordmark at the top.
+- A visible collapse control sits beside or immediately below the wordmark.
+- Primary navigation is grouped under the workspace heading and contains exactly Dashboard, Analysis, and Data.
+- Secondary workspace tools contain Inbox, Reviews, and Settings. They are visually quieter than the primary destinations.
+- Each entry uses the existing approved icon set plus a Vietnamese or English label.
+- The bottom area may contain locale, account, or settings access only when those actions are not already available more clearly in the top bar.
+
+### 2.2 Desktop compact state
+
+- The sidebar is 72 pixels wide.
+- Only the DataBreeze brand mark, navigation icons, and expand control remain visible.
+- Every icon retains an accessible name and a native tooltip/title.
+- The current destination remains visually obvious.
+- The user's preference is remembered per browser device. No tenant, member, dataset, or source identity is persisted with this preference.
+
+### 2.3 Responsive state
+
+- Between 768 and 1023 pixels, compact mode is the default unless the user explicitly expands it.
+- Below 768 pixels, the sidebar opens as an expanded modal navigation drawer and closes on route selection, Escape, or explicit close.
+- Reduced-motion preference removes width and transform animation without removing state feedback.
+
+## 3. Information architecture
+
+The shell preserves exactly three primary destinations:
+
+1. **Dashboard** — governed visual canvas, filters, freshness, evidence, and agent.
+2. **Analysis** — complete AI conversation workspace with full conversation history.
+3. **Data** — datasets, sources, preparation, versions, and health.
+
+Secondary workspace tools are:
+
+- **Inbox** — governed incoming artifacts and review state.
+- **Reviews** — CSV/XLSX intake, preparation review, ETL evidence, and explicit acceptance.
+- **Settings** — members, agent grants, sessions, and workspace controls.
+
+Secondary tools stay reachable from the sidebar but do not become primary product destinations. Existing governed routes and authorization boundaries remain unchanged.
+
+## 4. Dashboard canvas
+
+- Dashboard renders directly on a pale blue canvas field.
+- The application sidebar is its only permanent left navigation.
+- The Dashboard must not render `Phân tích mới`, conversation search, or an analysis-history column.
+- The dashboard page, title, filters, freshness, autosave state, KPI widgets, charts, evidence affordances, and agent entry remain visible.
+- White widget surfaces use restrained blue-gray borders, moderate radii, quiet depth, and no decorative gradients.
+- The canvas uses available width in either sidebar state and reflows without horizontal clipping.
+- Moving, resizing, removing, restoring, and keyboard operations continue to use stable widget IDs and existing governed authoring commands.
+
+## 5. Shared workspace agent
+
+### 5.1 Compact entry point
+
+- Dashboard and Data show the shared agent button at the bottom-right.
+- Analysis does not render a second floating agent because Analysis is already the full agent interface.
+- The button uses the current DataBreeze brand asset and an accessible label.
+
+### 5.2 Agent panel structure
+
+Opening the agent displays a responsive AI chat panel:
+
+1. DataBreeze agent identity and current authorized context.
+2. Conversation switcher showing authorized conversations only.
+3. New conversation action inside the agent, not inside the Dashboard canvas.
+4. Scrollable message history with distinct user and assistant treatment.
+5. Context summary for selected dashboard, datasets, versions, filters, or widget target.
+6. Composer with send action, loading state, denial state, and retry-safe error state.
+7. Link to open the same conversation in the full Analysis destination.
+
+The panel is approximately 420 pixels wide on wide screens, becomes a right-side sheet on medium screens, and a full-height bottom sheet on narrow screens.
+
+### 5.3 Conversation behavior
+
+- The compact agent and Analysis consume the same authorized conversation summaries and active-conversation identity.
+- Switching a conversation restores its permitted dashboard/dataset/version context without exposing hidden resources.
+- Creating a conversation inherits clearly displayed current scope or asks for scope before sending.
+- Conversation continuation reauthorizes the current member and current resource versions.
+- The UI never fabricates prior messages, source values, or results when the conversation API is unavailable.
+
+## 6. Dashboard chart proposal journey
+
+When the user asks to show something on the Dashboard:
+
+1. The agent records the question in the active authorized conversation.
+2. The system resolves a typed analysis plan against current permissions and exact dataset versions.
+3. The agent explains the proposed analytical framing in concise language.
+4. The panel displays only compatible allowlisted chart proposal cards.
+5. Each card shows chart type, title, rationale, supported dimensions/metric, important assumptions, evidence behavior, and a bounded preview state.
+6. The user selects one or more cards.
+7. The primary confirmation names the exact consequence, for example `Thêm 2 biểu đồ vào canvas`.
+8. Only that confirmation may create the next immutable dashboard version and place widgets on the canvas.
+9. The canvas communicates saving, saved, failed, or conflict state and focuses the first inserted widget when successful.
+
+The agent never generates executable UI, arbitrary chart code, SQL, JavaScript, or authoritative numeric values. It never publishes, broadens audience, changes permissions, or silently alters the shared dashboard.
+
+## 7. Page organization and visual system
+
+All first-party Web pages use:
+
+- Be Vietnam Pro.
+- Cobalt `#075DE8` for primary actions and selection.
+- Deep blue `#102A63` for primary text and selected navigation.
+- Pale blue-gray `#F4F7FC` or equivalent canvas background.
+- White working surfaces with blue-gray borders.
+- Moderate 10–18 pixel radii based on hierarchy.
+- Shadows with no more than 8 pixels of blur when paired with borders.
+- No decorative gradients, oversized marketing headings, emoji icons, handcrafted placeholder art, or excessive pill containers.
+
+Each page follows the same information order:
+
+1. Compact breadcrumb or eyebrow when needed.
+2. One clear page title and explanatory sentence.
+3. Current state or critical action.
+4. Primary work surface.
+5. Secondary details and evidence.
+
+Loading, empty, unavailable, unauthorized, conflict, and success are visibly distinct. An unavailable backend never appears as confirmed-empty data.
+
+## 8. Accessibility and interaction
+
+- Sidebar collapse/expand is a real button with `aria-expanded` and an explicit label.
+- Navigation remains reachable and understandable at 200 percent zoom.
+- Agent conversation switching, proposal selection, confirmation, and close are keyboard operable.
+- Focus moves into the agent on open, returns to the trigger on close, and moves to an inserted widget after confirmed placement.
+- Icon-only navigation keeps accessible text and tooltip titles.
+- Forced-colors and reduced-motion states remain functional.
+- Vietnamese is complete and default; English remains complete.
+
+## 9. Verification
+
+Implementation is not complete until the following pass:
+
+- Unit tests for sidebar default, collapse, remembered preference, icon-only labels, mobile drawer, and route selection.
+- Shell tests proving exactly three primary destinations and the quieter secondary tool group.
+- Dashboard tests proving no analysis-history or `Phân tích mới` appears in the canvas.
+- Agent tests for conversation switching, new conversation, contextual history, message composition, Analysis deep link, proposal selection, explicit confirmation, and no silent mutation.
+- Existing dashboard authoring, tenant-isolation, generated-contract, and permission tests.
+- Web TypeScript check, full Web tests, production build, bundle budget, and local container health.
+- Signed-in browser comparison against the approved dashboard reference at desktop and narrow widths.
+
+## 10. Normative delta
+
+This design replaces the earlier presentation rule that the Dashboard permanently hosts a retractable analysis-history panel. Conversation history now lives in the full Analysis destination and inside the opened compact agent. The global application rail becomes an adaptive sidebar that can display labels. The requirement for exactly three primary destinations, one shared agent, explicit chart confirmation, governed typed plans, and a pale-blue dashboard canvas remains unchanged.
+
diff --git a/infrastructure/lightsail/README.md b/infrastructure/lightsail/README.md
index 37f75669..34633443 100644
--- a/infrastructure/lightsail/README.md
+++ b/infrastructure/lightsail/README.md
@@ -1,5 +1,7 @@
# DataBreeze Lightsail pilot
+The complete local-to-pilot topology is documented in [Local development and Lightsail pilot](../../docs/architecture/local-and-pilot-development.md).
+
This is the low-cost single-server pilot profile. It is intended for a small
two-month validation period, not high-availability customer production. One
Lightsail Linux instance runs Caddy, Web, API, PostgreSQL, Redis, and MinIO.
diff --git a/infrastructure/local/README.md b/infrastructure/local/README.md
index 031417d2..8d5ee681 100644
--- a/infrastructure/local/README.md
+++ b/infrastructure/local/README.md
@@ -1,5 +1,8 @@
# Local Infrastructure
+For the relationship between the HMR watcher profile, built local gateway,
+and Lightsail deployment, see [Local development and Lightsail pilot](../../docs/architecture/local-and-pilot-development.md).
+
This directory contains the disposable services used by the DataBreeze control
plane during development. It is deliberately provider-neutral: application
code talks to PostgreSQL, Redis, S3-compatible object storage, SMTP, and OTLP
@@ -32,6 +35,30 @@ browser session deliberately retains `HttpOnly`, `Secure`, and `SameSite=Lax`
cookies. The local CA and synthetic keys are development material only and
must never be copied into a deployment.
+For normal product development, keep this Docker stack running and use host
+watchers for the application processes:
+
+```text
+corepack pnpm dev:infra
+corepack pnpm dev:api
+corepack pnpm dev:web
+```
+
+The Web URL is ; it uses Vite HMR and
+proxies API paths to the watched host API at . The
+`dev:api` watcher uses the database-backed local composition, runs Prisma
+generation/migrations, and talks to the Docker PostgreSQL, Redis, and Mailpit
+services. Registration, OTP, sign-in, refresh, logout, and durable data
+changes therefore exercise the real local backend while Web source changes
+update without a rebuild. The pilot/production Caddy URL is for built-image
+validation, not HMR.
+
+For this HMR profile, use the loopback HTTP URL above. The built gateway is a
+separate HTTPS endpoint at ; opening it as
+`http://localhost:8443` produces “Client sent an HTTP request to an HTTPS
+server”. It serves the built Web image and intentionally keeps Secure cookies;
+it is not the hot-reload endpoint.
+
The stack is defined in [`compose.yml`](compose.yml). All state is held in
named volumes prefixed by the Compose project name; no repository directory is
mounted for database, object, or mail data. The volumes are disposable and are
diff --git a/package.json b/package.json
index 97d351bf..1feb8aab 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,10 @@
"ci:licenses": "node tools/repo-cli/src/check-license-policy.mjs",
"ci:containers": "node tools/repo-cli/src/check-container-policy.mjs",
"contracts:check": "corepack pnpm --filter @databreeze/contracts contract:check",
+ "dev:api": "node tools/repo-cli/src/dev-stack.mjs api",
+ "dev:infra": "node tools/repo-cli/src/dev-stack.mjs infra",
+ "dev:stack": "node tools/repo-cli/src/dev-stack.mjs stack",
+ "dev:web": "node tools/repo-cli/src/dev-stack.mjs web",
"format": "prettier --write .",
"format:check": "prettier --check .",
"lint": "eslint . && node tools/repo-cli/src/check-dependency-boundaries.mjs",
diff --git a/packages/contracts/compatibility/published.json b/packages/contracts/compatibility/published.json
index 670565a4..33602b3f 100644
--- a/packages/contracts/compatibility/published.json
+++ b/packages/contracts/compatibility/published.json
@@ -14,7 +14,7 @@
{
"contractVersion": 3,
"baseline": "compatibility/v3/baseline.json",
- "sha256": "0812c49427345f25d9169f47960c658a316e5b074ae5f89e053a02fad81cecbb"
+ "sha256": "fe8a3c9532a428ed9f2543654d18cdacb2b5a6b0fe1778d0974ec01b07e80f7d"
}
]
}
diff --git a/packages/contracts/compatibility/v3/baseline.json b/packages/contracts/compatibility/v3/baseline.json
index a84c7bfa..f47002e2 100644
--- a/packages/contracts/compatibility/v3/baseline.json
+++ b/packages/contracts/compatibility/v3/baseline.json
@@ -83,7 +83,7 @@
},
{
"path": "generated/typescript/v3/validation.mjs",
- "sha256": "9e056c035d4ad21980173b724d39d941596a1a767bc682c226dfeb18619c4e84"
+ "sha256": "6452e4681a5232ac33beb1ec6a51c95528bc9f12b2cff39322743d9b0787611b"
}
],
"publicPackageSurfaces": [
diff --git a/services/api/package.json b/services/api/package.json
index 2e2dfe21..77749bb6 100644
--- a/services/api/package.json
+++ b/services/api/package.json
@@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"build": "tsc --project tsconfig.build.json",
+ "dev": "node ../../tools/repo-cli/src/api-dev.mjs",
"lint": "eslint .",
"openapi:check": "tsc --project tsconfig.build.json && node scripts/generate-openapi.mjs --check && pnpm openapi:validate",
"openapi:generate": "tsc --project tsconfig.build.json && node scripts/generate-openapi.mjs",
diff --git a/services/api/src/features/iam/api/session-cookies.ts b/services/api/src/features/iam/api/session-cookies.ts
index 6649a9e0..3a652cf6 100644
--- a/services/api/src/features/iam/api/session-cookies.ts
+++ b/services/api/src/features/iam/api/session-cookies.ts
@@ -32,6 +32,17 @@ function cookiePathV1(path: string | undefined): string {
throw new Error('Cookie path is invalid');
}
+function secureCookieV1(): boolean {
+ // The only exception is the explicitly selected loopback HMR profile. It
+ // never applies to pilot/production and is accepted only alongside the
+ // local composition's exact loopback origin validation.
+ return !(
+ process.env['NODE_ENV'] === 'production' &&
+ process.env['DATABREEZE_RUNTIME_PROFILE'] === 'local' &&
+ process.env['DATABREEZE_LOCAL_HMR_HTTP'] === 'true'
+ );
+}
+
export function serializeCookieV1(name: string, value: string, options: CookieOptionsV1): string {
if (!validCookieNameV1(name) || !validCookieValueV1(value)) {
throw new Error('Cookie name or value is invalid');
@@ -44,7 +55,7 @@ export function serializeCookieV1(name: string, value: string, options: CookieOp
`Max-Age=${options.maxAgeSeconds}`,
`Path=${cookiePathV1(options.path)}`,
options.httpOnly ? 'HttpOnly' : undefined,
- 'Secure',
+ secureCookieV1() ? 'Secure' : undefined,
'SameSite=Lax',
]
.filter((part): part is string => part !== undefined)
@@ -61,7 +72,7 @@ export function clearCookieV1(
'Max-Age=0',
`Path=${cookiePathV1(options.path)}`,
options.httpOnly ? 'HttpOnly' : undefined,
- 'Secure',
+ secureCookieV1() ? 'Secure' : undefined,
'SameSite=Lax',
]
.filter((part): part is string => part !== undefined)
diff --git a/services/api/src/platform/http/request-context.ts b/services/api/src/platform/http/request-context.ts
index 2f8de0e7..7a0f6436 100644
--- a/services/api/src/platform/http/request-context.ts
+++ b/services/api/src/platform/http/request-context.ts
@@ -21,6 +21,10 @@ const requestContexts = new WeakMap();
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const traceparentPattern = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/i;
+function isLoopbackHost(hostname: string): boolean {
+ return hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '::1';
+}
+
export type CorrelationHeaderResult =
| { readonly accepted: true; readonly correlationId: string }
| { readonly accepted: false };
@@ -46,11 +50,23 @@ export function validateRequestContextOptionsV1(
if (environment !== 'production') return;
const origins = options.csrf?.allowedOrigins;
if (!origins || origins.length === 0) throw new Error('CSRF_ALLOWED_ORIGINS_REQUIRED');
+ const localHmrHttp =
+ environment === 'production' &&
+ process.env['DATABREEZE_RUNTIME_PROFILE'] === 'local' &&
+ process.env['DATABREEZE_LOCAL_HMR_HTTP'] === 'true';
if (
origins.some((origin) => {
try {
const parsed = new URL(origin);
- return parsed.protocol !== 'https:' || parsed.username !== '' || parsed.password !== '';
+ const httpsOrigin = parsed.protocol === 'https:';
+ const loopbackHmrOrigin =
+ localHmrHttp &&
+ parsed.protocol === 'http:' &&
+ isLoopbackHost(parsed.hostname) &&
+ origin === parsed.origin;
+ return (
+ (!httpsOrigin && !loopbackHmrOrigin) || parsed.username !== '' || parsed.password !== ''
+ );
} catch {
return true;
}
diff --git a/services/api/src/platform/local-database.composition.ts b/services/api/src/platform/local-database.composition.ts
index 68ea54d2..8f4db721 100644
--- a/services/api/src/platform/local-database.composition.ts
+++ b/services/api/src/platform/local-database.composition.ts
@@ -251,22 +251,26 @@ function localRedisUrl(environment: RuntimeEnvironment): string {
}
}
-function localHttpsOrigin(
+function localBrowserOrigin(
environment: RuntimeEnvironment,
profile: typeof LOCAL_RUNTIME_PROFILE | typeof PILOT_RUNTIME_PROFILE,
): string {
- const candidate =
- environment[
- profile === PILOT_RUNTIME_PROFILE
- ? 'DATABREEZE_PILOT_HTTPS_ORIGIN'
- : 'DATABREEZE_LOCAL_HTTPS_ORIGIN'
- ]?.trim();
+ const hmrHttp =
+ profile === LOCAL_RUNTIME_PROFILE && environment['DATABREEZE_LOCAL_HMR_HTTP'] === 'true';
+ const hmrCandidate = environment['DATABREEZE_LOCAL_HMR_ORIGIN']?.trim();
+ const candidate = hmrHttp
+ ? hmrCandidate
+ : environment[
+ profile === PILOT_RUNTIME_PROFILE
+ ? 'DATABREEZE_PILOT_HTTPS_ORIGIN'
+ : 'DATABREEZE_LOCAL_HTTPS_ORIGIN'
+ ]?.trim();
if (!candidate) throw new Error(LOCAL_HTTPS_ORIGIN_ERROR);
try {
const parsed = new URL(candidate);
if (
- parsed.protocol !== 'https:' ||
- (profile === LOCAL_RUNTIME_PROFILE
+ (hmrHttp ? parsed.protocol !== 'http:' : parsed.protocol !== 'https:') ||
+ (hmrHttp || profile === LOCAL_RUNTIME_PROFILE
? !isLoopback(parsed.hostname)
: isLoopback(parsed.hostname)) ||
parsed.username !== '' ||
@@ -400,7 +404,7 @@ async function createComposeDatabaseComposition(
}
const connectionString = localDatabaseUrl(environment);
const redisUrl = localRedisUrl(environment);
- const httpsOrigin = localHttpsOrigin(environment, profile);
+ const httpsOrigin = localBrowserOrigin(environment, profile);
const emailProvider = localEmailProvider(environment);
const smtpOptions = emailProvider === 'mailpit' ? localSmtpOptions(environment) : undefined;
const gmailSmtpOptions =
diff --git a/services/api/test/features/iam/session-cookies.test.ts b/services/api/test/features/iam/session-cookies.test.ts
index 5ef18033..e0a18c3d 100644
--- a/services/api/test/features/iam/session-cookies.test.ts
+++ b/services/api/test/features/iam/session-cookies.test.ts
@@ -28,6 +28,32 @@ void test('serializes bounded session cookies with explicit browser security att
);
});
+void test('[WEB-004] local HMR uses non-secure cookies only for the explicit loopback profile', () => {
+ const previousProfile = process.env['DATABREEZE_RUNTIME_PROFILE'];
+ const previousHmr = process.env['DATABREEZE_LOCAL_HMR_HTTP'];
+ const previousNodeEnv = process.env['NODE_ENV'];
+ process.env['NODE_ENV'] = 'production';
+ process.env['DATABREEZE_RUNTIME_PROFILE'] = 'local';
+ process.env['DATABREEZE_LOCAL_HMR_HTTP'] = 'true';
+ try {
+ assert.doesNotMatch(
+ serializeCookieV1(REFRESH_COOKIE_NAME_V1, refreshToken, {
+ httpOnly: true,
+ maxAgeSeconds: 2_592_000,
+ }),
+ /Secure/u,
+ );
+ assert.doesNotMatch(clearCookieV1(REFRESH_COOKIE_NAME_V1, { httpOnly: true }), /Secure/u);
+ } finally {
+ if (previousProfile === undefined) delete process.env['DATABREEZE_RUNTIME_PROFILE'];
+ else process.env['DATABREEZE_RUNTIME_PROFILE'] = previousProfile;
+ if (previousHmr === undefined) delete process.env['DATABREEZE_LOCAL_HMR_HTTP'];
+ else process.env['DATABREEZE_LOCAL_HMR_HTTP'] = previousHmr;
+ if (previousNodeEnv === undefined) delete process.env['NODE_ENV'];
+ else process.env['NODE_ENV'] = previousNodeEnv;
+ }
+});
+
void test('reads one exact cookie value and fails closed for ambiguity or malformed input', () => {
assert.equal(
readCookieValueV1(`${REFRESH_COOKIE_NAME_V1}=${refreshToken}`, REFRESH_COOKIE_NAME_V1),
diff --git a/services/api/test/platform/http/csrf-protection.test.ts b/services/api/test/platform/http/csrf-protection.test.ts
index e5647737..6763b2a1 100644
--- a/services/api/test/platform/http/csrf-protection.test.ts
+++ b/services/api/test/platform/http/csrf-protection.test.ts
@@ -27,6 +27,34 @@ void test('production request context requires explicit HTTPS browser origins',
);
});
+void test('[WEB-004] production-shaped local HMR accepts only loopback HTTP when explicitly enabled', () => {
+ const previousProfile = process.env['DATABREEZE_RUNTIME_PROFILE'];
+ const previousHmr = process.env['DATABREEZE_LOCAL_HMR_HTTP'];
+ process.env['DATABREEZE_RUNTIME_PROFILE'] = 'local';
+ process.env['DATABREEZE_LOCAL_HMR_HTTP'] = 'true';
+ try {
+ assert.doesNotThrow(() =>
+ validateRequestContextOptionsV1(
+ { csrf: { allowedOrigins: ['http://127.0.0.1:5173'] } },
+ 'production',
+ ),
+ );
+ assert.throws(
+ () =>
+ validateRequestContextOptionsV1(
+ { csrf: { allowedOrigins: ['http://example.test'] } },
+ 'production',
+ ),
+ /CSRF_ALLOWED_ORIGINS_INVALID/u,
+ );
+ } finally {
+ if (previousProfile === undefined) delete process.env['DATABREEZE_RUNTIME_PROFILE'];
+ else process.env['DATABREEZE_RUNTIME_PROFILE'] = previousProfile;
+ if (previousHmr === undefined) delete process.env['DATABREEZE_LOCAL_HMR_HTTP'];
+ else process.env['DATABREEZE_LOCAL_HMR_HTTP'] = previousHmr;
+ }
+});
+
void test('allows safe methods and non-cookie clients without a CSRF token', () => {
assert.deepEqual(evaluateCsrfRequestV1({ method: 'GET', headers: {} }, { allowedOrigins }), {
accepted: true,
diff --git a/services/api/test/platform/local-database-composition.test.ts b/services/api/test/platform/local-database-composition.test.ts
index 50bd7528..22df0fde 100644
--- a/services/api/test/platform/local-database-composition.test.ts
+++ b/services/api/test/platform/local-database-composition.test.ts
@@ -39,6 +39,12 @@ const environment = {
DATABREEZE_SERVICE_ACCOUNT_SECRET_ENVELOPE_KEY: key(4),
} as const;
+const hmrEnvironment = {
+ ...environment,
+ DATABREEZE_LOCAL_HMR_HTTP: 'true',
+ DATABREEZE_LOCAL_HMR_ORIGIN: 'http://127.0.0.1:5173',
+} as const;
+
const pilotEnvironment = {
...environment,
DATABREEZE_RUNTIME_PROFILE: PILOT_RUNTIME_PROFILE,
@@ -127,6 +133,26 @@ void test('[FND-003, IAM-005, IAM-022, IAM-023] local profile composes durable P
]);
});
+void test('[FND-003, WEB-004] local HMR profile allows only the explicit loopback browser origin', async () => {
+ const composition = await createLocalDatabaseComposition(hmrEnvironment, {
+ createClient: () => databaseClient([]),
+ createRedisClient: () => ({
+ connect: async () => undefined,
+ disconnect: async () => undefined,
+ eval: async () => 1,
+ }),
+ createSmtpSender: () => ({ send: async () => undefined }),
+ });
+
+ try {
+ assert.deepEqual(composition.options.requestContext?.csrf?.allowedOrigins, [
+ hmrEnvironment.DATABREEZE_LOCAL_HMR_ORIGIN,
+ ]);
+ } finally {
+ await composition.disconnect();
+ }
+});
+
void test('[IAM-022] explicit local Gmail provider composes TLS SMTP delivery without changing the default Mailpit path', async () => {
let received: unknown;
const composition = await createLocalDatabaseComposition(gmailEnvironment, {
diff --git a/tools/repo-cli/src/api-dev.mjs b/tools/repo-cli/src/api-dev.mjs
new file mode 100644
index 00000000..400f0137
--- /dev/null
+++ b/tools/repo-cli/src/api-dev.mjs
@@ -0,0 +1,88 @@
+import { spawn, spawnSync } from 'node:child_process';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
+const pnpmExecutable = 'corepack';
+const apiDirectory = path.join(repositoryRoot, 'services', 'api');
+
+function runPnpm(args) {
+ const result = spawnSync(pnpmExecutable, ['pnpm', ...args], {
+ cwd: repositoryRoot,
+ env: { ...process.env },
+ shell: process.platform === 'win32',
+ stdio: 'inherit',
+ windowsHide: false,
+ });
+ if (result.error) throw result.error;
+ if (result.status !== 0) process.exit(result.status ?? 1);
+}
+
+function start(command, args) {
+ return spawn(command, args, {
+ cwd: repositoryRoot,
+ env: {
+ ...process.env,
+ NODE_ENV: process.env.NODE_ENV ?? 'development',
+ HOST: '127.0.0.1',
+ PORT: '3000',
+ },
+ shell: process.platform === 'win32',
+ stdio: 'inherit',
+ windowsHide: false,
+ });
+}
+
+function stop(child) {
+ if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM');
+}
+
+// Keep this command self-starting: the user may run only dev:api after Docker
+// Desktop is ready. The lifecycle command is idempotent when dependencies are
+// already healthy and never starts the built API/Web containers.
+runPnpm(['local:services', 'start']);
+runPnpm(['--filter', '@databreeze/api', 'prisma:generate']);
+runPnpm([
+ '--filter',
+ '@databreeze/api',
+ 'exec',
+ 'prisma',
+ 'migrate',
+ 'deploy',
+ '--config',
+ 'prisma.config.ts',
+]);
+runPnpm(['--filter', '@databreeze/domain', 'build']);
+runPnpm(['--filter', '@databreeze/telemetry', 'build']);
+runPnpm(['--filter', '@databreeze/api', 'build']);
+
+const compiler = start(pnpmExecutable, [
+ 'pnpm',
+ '--filter',
+ '@databreeze/api',
+ 'exec',
+ 'tsc',
+ '--project',
+ 'tsconfig.build.json',
+ '--watch',
+ '--preserveWatchOutput',
+]);
+const server = start(process.execPath, ['--watch', path.join(apiDirectory, 'dist', 'main.js')]);
+
+let stopping = false;
+function shutdown(code = 0) {
+ if (stopping) return;
+ stopping = true;
+ stop(compiler);
+ stop(server);
+ process.exitCode = code;
+}
+
+process.once('SIGINT', () => shutdown(0));
+process.once('SIGTERM', () => shutdown(0));
+compiler.once('exit', (code) => {
+ if (!stopping && code !== 0) shutdown(code ?? 1);
+});
+server.once('exit', (code) => {
+ if (!stopping && code !== 0) shutdown(code ?? 1);
+});
diff --git a/tools/repo-cli/src/dev-stack.mjs b/tools/repo-cli/src/dev-stack.mjs
new file mode 100644
index 00000000..0abe6c4d
--- /dev/null
+++ b/tools/repo-cli/src/dev-stack.mjs
@@ -0,0 +1,197 @@
+import { existsSync, readFileSync } from 'node:fs';
+import { spawn } from 'node:child_process';
+import path from 'node:path';
+import { fileURLToPath, URL } from 'node:url';
+
+const PNPM_EXECUTABLE = 'corepack';
+const REPOSITORY_ROOT = path.resolve(
+ path.dirname(fileURLToPath(import.meta.url)),
+ '..',
+ '..',
+ '..',
+);
+
+export const DEV_COMMANDS = Object.freeze({
+ infra: ['local:services', 'start'],
+ api: ['--filter', '@databreeze/api', 'dev'],
+ web: ['--filter', '@databreeze/web', 'dev', '--', '--host', '127.0.0.1', '--port', '5173'],
+});
+
+export const DEV_WORKING_DIRECTORIES = Object.freeze({
+ web: 'apps/web',
+});
+
+export const DEV_WEB_PREREQUISITE = Object.freeze(['--filter', '@databreeze/domain', 'build']);
+
+const LOCAL_ENV_FILE = path.join(REPOSITORY_ROOT, 'infrastructure', 'local', '.env');
+const LOCAL_ENV_EXAMPLE_FILE = path.join(
+ REPOSITORY_ROOT,
+ 'infrastructure',
+ 'local',
+ '.env.example',
+);
+
+function readLocalEnvironmentFile() {
+ const filename = existsSync(LOCAL_ENV_FILE) ? LOCAL_ENV_FILE : LOCAL_ENV_EXAMPLE_FILE;
+ const values = {};
+ for (const line of readFileSync(filename, 'utf8').split(/\r?\n/u)) {
+ const match = /^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*?)\s*$/u.exec(line);
+ if (match) values[match[1]] = match[2].replace(/^(['"])(.*)\1$/u, '$2');
+ }
+ return values;
+}
+
+function localPort(values, name, fallback) {
+ const value = Number(values[name] ?? fallback);
+ if (!Number.isInteger(value) || value < 1024 || value > 65535) {
+ throw new Error(`${name} must be an integer between 1024 and 65535`);
+ }
+ return value;
+}
+
+function localUrlWithLoopbackHost(raw, fallback) {
+ try {
+ const value = new URL(raw);
+ value.hostname = '127.0.0.1';
+ if (value.port === '') value.port = String(fallback);
+ return value.toString();
+ } catch {
+ throw new Error('Local development connection URL is invalid');
+ }
+}
+
+/** Host-watcher environment for the real local Postgres/Redis/Mailpit profile. */
+export function databaseBackedDevelopmentEnvironment(overrides = {}) {
+ const values = { ...readLocalEnvironmentFile(), ...process.env, ...overrides };
+ const postgresPort = localPort(values, 'POSTGRES_PORT', 5432);
+ const redisPort = localPort(values, 'REDIS_PORT', 6379);
+ const minioPort = localPort(values, 'MINIO_API_PORT', 9000);
+ const smtpPort = localPort(values, 'MAILPIT_SMTP_PORT', 1025);
+ const databaseUrl = values.DATABASE_URL
+ ? localUrlWithLoopbackHost(values.DATABASE_URL, postgresPort)
+ : `postgresql://${encodeURIComponent(values.POSTGRES_USER ?? 'databreeze')}:${encodeURIComponent(values.POSTGRES_PASSWORD ?? 'databreeze-local-change-me')}@127.0.0.1:${postgresPort}/${values.POSTGRES_DB ?? 'databreeze'}?schema=public`;
+ const emailProvider = values.DATABREEZE_LOCAL_EMAIL_PROVIDER ?? 'mailpit';
+ const smtpHost = emailProvider === 'mailpit' ? '127.0.0.1' : values.DATABREEZE_IAM_SMTP_HOST;
+ const smtpPortValue =
+ emailProvider === 'mailpit' ? String(smtpPort) : values.DATABREEZE_IAM_SMTP_PORT;
+
+ return {
+ ...localDevelopmentEnvironment(),
+ NODE_ENV: 'production',
+ DATABREEZE_RUNTIME_PROFILE: 'local',
+ DATABASE_URL: databaseUrl,
+ DATABREEZE_REDIS_URL: `redis://127.0.0.1:${redisPort}`,
+ DATABREEZE_LOCAL_HMR_HTTP: 'true',
+ DATABREEZE_LOCAL_HMR_ORIGIN: 'http://127.0.0.1:5173',
+ DATABREEZE_LOCAL_EMAIL_PROVIDER: emailProvider,
+ DATABREEZE_IAM_SMTP_HOST: smtpHost,
+ DATABREEZE_IAM_SMTP_PORT: smtpPortValue,
+ DATABREEZE_IAM_SMTP_USERNAME: values.DATABREEZE_IAM_SMTP_USERNAME ?? '',
+ DATABREEZE_IAM_SMTP_APP_PASSWORD: values.DATABREEZE_IAM_SMTP_APP_PASSWORD ?? '',
+ DATABREEZE_IAM_EMAIL_FROM_ADDRESS:
+ values.DATABREEZE_IAM_EMAIL_FROM_ADDRESS ?? 'verify@databreeze.local',
+ DATABREEZE_IAM_EMAIL_VERIFICATION_DIGEST_KEY:
+ values.DATABREEZE_IAM_EMAIL_VERIFICATION_DIGEST_KEY,
+ DATABREEZE_IAM_EMAIL_VERIFICATION_ENVELOPE_KEY:
+ values.DATABREEZE_IAM_EMAIL_VERIFICATION_ENVELOPE_KEY,
+ DATABREEZE_IAM_REGISTRATION_ADMISSION_KEY: values.DATABREEZE_IAM_REGISTRATION_ADMISSION_KEY,
+ DATABREEZE_SERVICE_ACCOUNT_SECRET_ENVELOPE_KEY:
+ values.DATABREEZE_SERVICE_ACCOUNT_SECRET_ENVELOPE_KEY,
+ DATABREEZE_LOCAL_MINIO_ENDPOINT: `http://127.0.0.1:${minioPort}`,
+ DATABREEZE_LOCAL_MINIO_ACCESS_KEY: values.MINIO_ROOT_USER ?? 'databreeze',
+ DATABREEZE_LOCAL_MINIO_SECRET_KEY: values.MINIO_ROOT_PASSWORD ?? 'databreeze-local-change-me',
+ DATABREEZE_LOCAL_MINIO_BUCKET: values.MINIO_BUCKET_ARTIFACTS ?? 'databreeze-artifacts',
+ VITE_DATABREEZE_API_BASE_URL: '',
+ VITE_DATABREEZE_DEMO_MODE: 'false',
+ };
+}
+
+export function localDevelopmentEnvironment(overrides = {}) {
+ return {
+ NODE_ENV: 'development',
+ HOST: '127.0.0.1',
+ PORT: '3000',
+ VITE_DATABREEZE_API_PROXY_TARGET: 'http://127.0.0.1:3000',
+ ...overrides,
+ };
+}
+
+/** Vite HMR keeps NODE_ENV=development so React Refresh stays enabled. */
+export function webDevelopmentEnvironment(overrides = {}) {
+ return {
+ ...databaseBackedDevelopmentEnvironment(overrides),
+ NODE_ENV: 'development',
+ };
+}
+
+export function renderDevelopmentInstructions() {
+ return `Local DataBreeze development
+
+Terminal A — Docker infrastructure only:
+ corepack pnpm dev:infra
+ This starts PostgreSQL, Redis, MinIO, Mailpit, and OpenTelemetry with localhost-only ports.
+
+Terminal B — watched API process:
+ corepack pnpm dev:api
+ API health: http://127.0.0.1:3000/health/ready
+
+Terminal C — Vite HMR frontend:
+ corepack pnpm dev:web
+ Open http://127.0.0.1:5173/vi-VN/sign-in
+ Edit apps/web/src/* and Vite HMR updates the browser without a rebuild.
+
+The watched API uses the database-backed local composition against the Docker services,
+and Vite proxies /v1, /v3, and /health to it. Registration, OTP, sign-in, refresh, and
+logout therefore use real Postgres/Redis/Mailpit state. This loopback-only HMR profile
+uses development HTTP cookies; the built local gateway at https://localhost:8443 keeps
+Secure cookies and is still the production-shaped validation path.`;
+}
+
+function spawnPnpm(args, { env = process.env, cwd = REPOSITORY_ROOT } = {}) {
+ return spawn(PNPM_EXECUTABLE, ['pnpm', ...args], {
+ cwd,
+ env,
+ stdio: 'inherit',
+ shell: process.platform === 'win32',
+ windowsHide: false,
+ });
+}
+
+async function runProcess(args, options = {}) {
+ const child = spawnPnpm(args, options);
+ const exitCode = await new Promise((resolve, reject) => {
+ child.once('error', reject);
+ child.once('exit', (code, signal) => resolve(code ?? (signal === null ? 1 : 143)));
+ });
+ process.exitCode = exitCode;
+ return exitCode;
+}
+
+export async function main(argv = process.argv.slice(2)) {
+ const command = argv[0] ?? 'help';
+ if (command === 'help' || command === 'stack') {
+ console.log(renderDevelopmentInstructions());
+ return 0;
+ }
+ if (command === 'infra') return runProcess(DEV_COMMANDS.infra);
+ if (command === 'api') {
+ return runProcess(DEV_COMMANDS.api, {
+ env: { ...process.env, ...databaseBackedDevelopmentEnvironment() },
+ });
+ }
+ if (command === 'web') {
+ const prerequisiteCode = await runProcess(DEV_WEB_PREREQUISITE, {
+ env: { ...process.env, ...localDevelopmentEnvironment() },
+ });
+ if (prerequisiteCode !== 0) return prerequisiteCode;
+ return runProcess(DEV_COMMANDS.web, {
+ cwd: path.resolve(REPOSITORY_ROOT, DEV_WORKING_DIRECTORIES.web),
+ env: { ...process.env, ...webDevelopmentEnvironment() },
+ });
+ }
+ throw new Error(`Unknown local development command: ${command}`);
+}
+
+if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ await main();
+}
diff --git a/tools/repo-cli/test/dev-stack.test.mjs b/tools/repo-cli/test/dev-stack.test.mjs
new file mode 100644
index 00000000..667b773d
--- /dev/null
+++ b/tools/repo-cli/test/dev-stack.test.mjs
@@ -0,0 +1,85 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import {
+ DEV_COMMANDS,
+ DEV_WORKING_DIRECTORIES,
+ DEV_WEB_PREREQUISITE,
+ databaseBackedDevelopmentEnvironment,
+ localDevelopmentEnvironment,
+ renderDevelopmentInstructions,
+ webDevelopmentEnvironment,
+} from '../src/dev-stack.mjs';
+
+test('local development commands keep infrastructure in Docker and app processes on the host', () => {
+ assert.deepEqual(DEV_COMMANDS, {
+ infra: ['local:services', 'start'],
+ api: ['--filter', '@databreeze/api', 'dev'],
+ web: ['--filter', '@databreeze/web', 'dev', '--', '--host', '127.0.0.1', '--port', '5173'],
+ });
+
+ assert.deepEqual(localDevelopmentEnvironment(), {
+ NODE_ENV: 'development',
+ HOST: '127.0.0.1',
+ PORT: '3000',
+ VITE_DATABREEZE_API_PROXY_TARGET: 'http://127.0.0.1:3000',
+ });
+});
+
+test('development instructions are explicit about the HMR URL and Docker-only services', () => {
+ const instructions = renderDevelopmentInstructions();
+
+ assert.match(instructions, /http:\/\/127\.0\.0\.1:5173\/vi-VN\/sign-in/u);
+ assert.match(instructions, /Vite HMR/u);
+ assert.match(instructions, /PostgreSQL.*Redis.*MinIO.*Mailpit/isu);
+ assert.match(instructions, /database-backed/iu);
+ assert.match(instructions, /8443.*production-shaped validation/isu);
+});
+
+test('database-backed development environment points host watchers at the Docker services', () => {
+ const environment = databaseBackedDevelopmentEnvironment({
+ DATABASE_URL:
+ 'postgresql://databreeze:databreeze-local-change-me@127.0.0.1:5432/databreeze?schema=public',
+ REDIS_PORT: '6379',
+ MINIO_API_PORT: '9000',
+ MAILPIT_SMTP_PORT: '1025',
+ DATABREEZE_LOCAL_EMAIL_PROVIDER: 'mailpit',
+ });
+
+ assert.equal(environment.NODE_ENV, 'production');
+ assert.equal(environment.DATABREEZE_RUNTIME_PROFILE, 'local');
+ assert.equal(environment.DATABREEZE_LOCAL_HMR_HTTP, 'true');
+ assert.equal(environment.DATABREEZE_LOCAL_HMR_ORIGIN, 'http://127.0.0.1:5173');
+ assert.equal(environment.DATABREEZE_REDIS_URL, 'redis://127.0.0.1:6379');
+ assert.equal(environment.DATABREEZE_IAM_SMTP_HOST, '127.0.0.1');
+ assert.equal(environment.DATABREEZE_IAM_SMTP_PORT, '1025');
+ assert.equal(environment.DATABREEZE_LOCAL_MINIO_ENDPOINT, 'http://127.0.0.1:9000');
+ assert.equal(environment.VITE_DATABREEZE_DEMO_MODE, 'false');
+ assert.equal(environment.VITE_DATABREEZE_API_BASE_URL, '');
+ assert.match(environment.DATABASE_URL, /@127\.0\.0\.1:5432\//u);
+});
+
+test('web development keeps Vite in development while using the database-backed local flags', () => {
+ const environment = webDevelopmentEnvironment({
+ DATABASE_URL:
+ 'postgresql://databreeze:databreeze-local-change-me@127.0.0.1:5432/databreeze?schema=public',
+ REDIS_PORT: '6379',
+ MINIO_API_PORT: '9000',
+ MAILPIT_SMTP_PORT: '1025',
+ DATABREEZE_LOCAL_EMAIL_PROVIDER: 'mailpit',
+ });
+
+ assert.equal(environment.NODE_ENV, 'development');
+ assert.equal(environment.VITE_DATABREEZE_DEMO_MODE, 'false');
+ assert.equal(environment.VITE_DATABREEZE_API_BASE_URL, '');
+ assert.equal(environment.DATABREEZE_RUNTIME_PROFILE, 'local');
+ assert.equal(environment.DATABREEZE_LOCAL_HMR_ORIGIN, 'http://127.0.0.1:5173');
+});
+
+test('web development builds runtime domain exports before starting Vite', () => {
+ assert.deepEqual(DEV_WEB_PREREQUISITE, ['--filter', '@databreeze/domain', 'build']);
+});
+
+test('web development starts from its package directory so workspace dependencies resolve', () => {
+ assert.equal(DEV_WORKING_DIRECTORIES.web, 'apps/web');
+});
|