| @@ -1,6 +1,6 @@ | |||
| @Campaign_Tracker.Server_HostAddress = http://localhost:5254 | |||
| GET {{Campaign_Tracker.Server_HostAddress}}/weatherforecast/ | |||
| GET {{Campaign_Tracker.Server_HostAddress}}/health | |||
| Accept: application/json | |||
| ### | |||
| @@ -1,26 +0,0 @@ | |||
| using Microsoft.AspNetCore.Mvc; | |||
| namespace Campaign_Tracker.Server.Controllers; | |||
| [ApiController] | |||
| [Route("[controller]")] | |||
| public class WeatherForecastController : ControllerBase | |||
| { | |||
| private static readonly string[] Summaries = | |||
| [ | |||
| "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" | |||
| ]; | |||
| [HttpGet(Name = "GetWeatherForecast")] | |||
| public IEnumerable<WeatherForecast> Get() | |||
| { | |||
| return Enumerable.Range(1, 5).Select(index => new WeatherForecast | |||
| { | |||
| Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)), | |||
| TemperatureC = Random.Shared.Next(-20, 55), | |||
| Summary = Summaries[Random.Shared.Next(Summaries.Length)] | |||
| }) | |||
| .ToArray(); | |||
| } | |||
| } | |||
| @@ -1,13 +0,0 @@ | |||
| namespace Campaign_Tracker.Server; | |||
| public class WeatherForecast | |||
| { | |||
| public DateOnly Date { get; set; } | |||
| public int TemperatureC { get; set; } | |||
| public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); | |||
| public string? Summary { get; set; } | |||
| } | |||
| @@ -113,8 +113,6 @@ GPT-5 Codex | |||
| - `campaign-tracker.sln` | |||
| - `Campaign_Tracker.Server/Campaign_Tracker.Server.csproj` | |||
| - `Campaign_Tracker.Server/Program.cs` | |||
| - `Campaign_Tracker.Server/WeatherForecast.cs` | |||
| - `Campaign_Tracker.Server/Controllers/WeatherForecastController.cs` | |||
| - `Campaign_Tracker.Server/appsettings.json` | |||
| - `Campaign_Tracker.Server/appsettings.Development.json` | |||
| - `Campaign_Tracker.Server/Properties/launchSettings.json` | |||
| @@ -1,6 +1,6 @@ | |||
| # Story 1.2: Workspace Shell & Ant Design Foundation | |||
| Status: ready-for-dev | |||
| Status: review | |||
| ## Story | |||
| @@ -19,18 +19,18 @@ so that I have a predictable, accessible, and keyboard-navigable operational env | |||
| ## Tasks / Subtasks | |||
| - [ ] Implement story behavior in aligned backend/frontend modules (AC: #1) | |||
| - [ ] Add or update API/service/UI components required by the story scope | |||
| - [ ] Keep legacy Access entities read-only and route writes to extension-layer structures | |||
| - [ ] Cover acceptance criteria #2 in implementation and tests (AC: #2) | |||
| - [ ] Add validation/error handling and UX state updates as needed | |||
| - [ ] Cover acceptance criteria #3 in implementation and tests (AC: #3) | |||
| - [ ] Add validation/error handling and UX state updates as needed | |||
| - [ ] Cover acceptance criteria #4 in implementation and tests (AC: #4) | |||
| - [ ] Add validation/error handling and UX state updates as needed | |||
| - [ ] Validate and document completion evidence | |||
| - [ ] Verify build/tests for touched modules | |||
| - [ ] Capture changed files and any migration/config implications | |||
| - [x] Implement story behavior in aligned backend/frontend modules (AC: #1) | |||
| - [x] Add or update API/service/UI components required by the story scope | |||
| - [x] Keep legacy Access entities read-only and route writes to extension-layer structures | |||
| - [x] Cover acceptance criteria #2 in implementation and tests (AC: #2) | |||
| - [x] Add validation/error handling and UX state updates as needed | |||
| - [x] Cover acceptance criteria #3 in implementation and tests (AC: #3) | |||
| - [x] Add validation/error handling and UX state updates as needed | |||
| - [x] Cover acceptance criteria #4 in implementation and tests (AC: #4) | |||
| - [x] Add validation/error handling and UX state updates as needed | |||
| - [x] Validate and document completion evidence | |||
| - [x] Verify build/tests for touched modules | |||
| - [x] Capture changed files and any migration/config implications | |||
| ## Dev Notes | |||
| @@ -59,13 +59,40 @@ GPT-5 Codex | |||
| ### Debug Log References | |||
| - Story generated from epic source and architecture/UX planning artifacts. | |||
| - 2026-05-05: Added failing frontend contract tests for workspace tokens, breakpoints, edit gating, right-panel collapse rules, and non-color-only statuses. | |||
| - 2026-05-05: Verified `npm test`, `npm run lint`, `npm run build`, and `dotnet test campaign-tracker.sln`. | |||
| ### Implementation Plan | |||
| - Replace the starter Vite screen with an Ant Design `ConfigProvider` and tri-pane workspace shell. | |||
| - Centralize UX tokens, status semantics, and breakpoint rules in a tested workspace contract module. | |||
| - Keep backend and legacy data untouched for this shell story; expose only read-only operational sample context in the UI. | |||
| ### Completion Notes List | |||
| - Story context created and marked ready-for-dev. | |||
| - Implemented the authenticated operations workspace shell with Ant Design `ConfigProvider`, compact theme tokens, left navigation pane, center operations grid, and right risk/provenance panel. | |||
| - Added responsive desktop behavior: compact tri-pane with collapsible right panel from 1280px to 1599px, persistent right panel at 1600px+, and reduced read mode with disabled editing below 1280px. | |||
| - Added visible 2px focus indicators and status indicators that pair semantic colors with icons and text labels. | |||
| - No backend write path was added; legacy Access data is represented as read-only source context and future updates are labeled for extension records. | |||
| ### File List | |||
| - `_bmad-output/implementation-artifacts/sprint-status.yaml` | |||
| - `_bmad-output/implementation-artifacts/1-2-workspace-shell-ant-design-foundation.md` | |||
| - `campaign-tracker-client/package.json` | |||
| - `campaign-tracker-client/package-lock.json` | |||
| - `campaign-tracker-client/src/App.css` | |||
| - `campaign-tracker-client/src/App.tsx` | |||
| - `campaign-tracker-client/src/index.css` | |||
| - `campaign-tracker-client/src/main.tsx` | |||
| - `campaign-tracker-client/src/workspace/WorkspaceShell.css` | |||
| - `campaign-tracker-client/src/workspace/WorkspaceShell.tsx` | |||
| - `campaign-tracker-client/src/workspace/workspaceContracts.test.ts` | |||
| - `campaign-tracker-client/src/workspace/workspaceContracts.ts` | |||
| ### Change Log | |||
| - 2026-05-05: Implemented Workspace Shell & Ant Design Foundation and marked story ready for review. | |||
| @@ -35,7 +35,7 @@ | |||
| # - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended) | |||
| generated: '2026-05-05T12:00:44-04:00' | |||
| last_updated: '2026-05-05T12:41:10-04:00' | |||
| last_updated: '2026-05-05T13:11:00-04:00' | |||
| project: 'Campaign_Tracker App' | |||
| project_key: 'NOKEY' | |||
| tracking_system: 'file-system' | |||
| @@ -44,7 +44,7 @@ story_location: '_bmad-output/implementation-artifacts' | |||
| development_status: | |||
| epic-1: in-progress | |||
| 1-1-project-initialization-solution-scaffold: review | |||
| 1-2-workspace-shell-ant-design-foundation: ready-for-dev | |||
| 1-2-workspace-shell-ant-design-foundation: review | |||
| 1-3-keycloak-realm-configuration-oidc-integration: ready-for-dev | |||
| 1-4-keycloak-role-mapping-application-authorization: ready-for-dev | |||
| 1-5-shared-audit-logging-infrastructure: ready-for-dev | |||
| @@ -7,9 +7,12 @@ | |||
| "dev": "vite", | |||
| "build": "tsc -b && vite build", | |||
| "lint": "eslint .", | |||
| "test": "vitest run", | |||
| "preview": "vite preview" | |||
| }, | |||
| "dependencies": { | |||
| "@ant-design/icons": "^5.6.1", | |||
| "antd": "^5.29.3", | |||
| "react": "^19.2.5", | |||
| "react-dom": "^19.2.5" | |||
| }, | |||
| @@ -25,7 +28,7 @@ | |||
| "globals": "^17.5.0", | |||
| "typescript": "~6.0.2", | |||
| "typescript-eslint": "^8.58.2", | |||
| "vite": "^8.0.10" | |||
| "vite": "^8.0.10", | |||
| "vitest": "^4.1.5" | |||
| } | |||
| } | |||
| @@ -1,184 +1,3 @@ | |||
| .counter { | |||
| font-size: 16px; | |||
| padding: 5px 10px; | |||
| border-radius: 5px; | |||
| color: var(--accent); | |||
| background: var(--accent-bg); | |||
| border: 2px solid transparent; | |||
| transition: border-color 0.3s; | |||
| margin-bottom: 24px; | |||
| &:hover { | |||
| border-color: var(--accent-border); | |||
| } | |||
| &:focus-visible { | |||
| outline: 2px solid var(--accent); | |||
| outline-offset: 2px; | |||
| } | |||
| } | |||
| .hero { | |||
| position: relative; | |||
| .base, | |||
| .framework, | |||
| .vite { | |||
| inset-inline: 0; | |||
| margin: 0 auto; | |||
| } | |||
| .base { | |||
| width: 170px; | |||
| position: relative; | |||
| z-index: 0; | |||
| } | |||
| .framework, | |||
| .vite { | |||
| position: absolute; | |||
| } | |||
| .framework { | |||
| z-index: 1; | |||
| top: 34px; | |||
| height: 28px; | |||
| transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) | |||
| scale(1.4); | |||
| } | |||
| .vite { | |||
| z-index: 0; | |||
| top: 107px; | |||
| height: 26px; | |||
| width: auto; | |||
| transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) | |||
| scale(0.8); | |||
| } | |||
| } | |||
| #center { | |||
| display: flex; | |||
| flex-direction: column; | |||
| gap: 25px; | |||
| place-content: center; | |||
| place-items: center; | |||
| flex-grow: 1; | |||
| @media (max-width: 1024px) { | |||
| padding: 32px 20px 24px; | |||
| gap: 18px; | |||
| } | |||
| } | |||
| #next-steps { | |||
| display: flex; | |||
| border-top: 1px solid var(--border); | |||
| text-align: left; | |||
| & > div { | |||
| flex: 1 1 0; | |||
| padding: 32px; | |||
| @media (max-width: 1024px) { | |||
| padding: 24px 20px; | |||
| } | |||
| } | |||
| .icon { | |||
| margin-bottom: 16px; | |||
| width: 22px; | |||
| height: 22px; | |||
| } | |||
| @media (max-width: 1024px) { | |||
| flex-direction: column; | |||
| text-align: center; | |||
| } | |||
| } | |||
| #docs { | |||
| border-right: 1px solid var(--border); | |||
| @media (max-width: 1024px) { | |||
| border-right: none; | |||
| border-bottom: 1px solid var(--border); | |||
| } | |||
| } | |||
| #next-steps ul { | |||
| list-style: none; | |||
| padding: 0; | |||
| display: flex; | |||
| gap: 8px; | |||
| margin: 32px 0 0; | |||
| .logo { | |||
| height: 18px; | |||
| } | |||
| a { | |||
| color: var(--text-h); | |||
| font-size: 16px; | |||
| border-radius: 6px; | |||
| background: var(--social-bg); | |||
| display: flex; | |||
| padding: 6px 12px; | |||
| align-items: center; | |||
| gap: 8px; | |||
| text-decoration: none; | |||
| transition: box-shadow 0.3s; | |||
| &:hover { | |||
| box-shadow: var(--shadow); | |||
| } | |||
| .button-icon { | |||
| height: 18px; | |||
| width: 18px; | |||
| } | |||
| } | |||
| @media (max-width: 1024px) { | |||
| margin-top: 20px; | |||
| flex-wrap: wrap; | |||
| justify-content: center; | |||
| li { | |||
| flex: 1 1 calc(50% - 8px); | |||
| } | |||
| a { | |||
| width: 100%; | |||
| justify-content: center; | |||
| box-sizing: border-box; | |||
| } | |||
| } | |||
| } | |||
| #spacer { | |||
| height: 88px; | |||
| border-top: 1px solid var(--border); | |||
| @media (max-width: 1024px) { | |||
| height: 48px; | |||
| } | |||
| } | |||
| .ticks { | |||
| position: relative; | |||
| width: 100%; | |||
| &::before, | |||
| &::after { | |||
| content: ''; | |||
| position: absolute; | |||
| top: -4.5px; | |||
| border: 5px solid transparent; | |||
| } | |||
| &::before { | |||
| left: 0; | |||
| border-left-color: var(--border); | |||
| } | |||
| &::after { | |||
| right: 0; | |||
| border-right-color: var(--border); | |||
| } | |||
| .app-shell { | |||
| min-height: 100vh; | |||
| } | |||
| @@ -1,121 +1,30 @@ | |||
| import { useState } from 'react' | |||
| import reactLogo from './assets/react.svg' | |||
| import viteLogo from './assets/vite.svg' | |||
| import heroImg from './assets/hero.png' | |||
| import { ConfigProvider, theme } from 'antd' | |||
| import './App.css' | |||
| import { WorkspaceShell } from './workspace/WorkspaceShell' | |||
| import { workspaceThemeTokens } from './workspace/workspaceContracts' | |||
| function App() { | |||
| const [count, setCount] = useState(0) | |||
| return ( | |||
| <> | |||
| <section id="center"> | |||
| <div className="hero"> | |||
| <img src={heroImg} className="base" width="170" height="179" alt="" /> | |||
| <img src={reactLogo} className="framework" alt="React logo" /> | |||
| <img src={viteLogo} className="vite" alt="Vite logo" /> | |||
| </div> | |||
| <div> | |||
| <h1>Get started</h1> | |||
| <p> | |||
| Edit <code>src/App.tsx</code> and save to test <code>HMR</code> | |||
| </p> | |||
| </div> | |||
| <button | |||
| type="button" | |||
| className="counter" | |||
| onClick={() => setCount((count) => count + 1)} | |||
| > | |||
| Count is {count} | |||
| </button> | |||
| </section> | |||
| <div className="ticks"></div> | |||
| <section id="next-steps"> | |||
| <div id="docs"> | |||
| <svg className="icon" role="presentation" aria-hidden="true"> | |||
| <use href="/icons.svg#documentation-icon"></use> | |||
| </svg> | |||
| <h2>Documentation</h2> | |||
| <p>Your questions, answered</p> | |||
| <ul> | |||
| <li> | |||
| <a href="https://vite.dev/" target="_blank"> | |||
| <img className="logo" src={viteLogo} alt="" /> | |||
| Explore Vite | |||
| </a> | |||
| </li> | |||
| <li> | |||
| <a href="https://react.dev/" target="_blank"> | |||
| <img className="button-icon" src={reactLogo} alt="" /> | |||
| Learn more | |||
| </a> | |||
| </li> | |||
| </ul> | |||
| </div> | |||
| <div id="social"> | |||
| <svg className="icon" role="presentation" aria-hidden="true"> | |||
| <use href="/icons.svg#social-icon"></use> | |||
| </svg> | |||
| <h2>Connect with us</h2> | |||
| <p>Join the Vite community</p> | |||
| <ul> | |||
| <li> | |||
| <a href="https://github.com/vitejs/vite" target="_blank"> | |||
| <svg | |||
| className="button-icon" | |||
| role="presentation" | |||
| aria-hidden="true" | |||
| > | |||
| <use href="/icons.svg#github-icon"></use> | |||
| </svg> | |||
| GitHub | |||
| </a> | |||
| </li> | |||
| <li> | |||
| <a href="https://chat.vite.dev/" target="_blank"> | |||
| <svg | |||
| className="button-icon" | |||
| role="presentation" | |||
| aria-hidden="true" | |||
| > | |||
| <use href="/icons.svg#discord-icon"></use> | |||
| </svg> | |||
| Discord | |||
| </a> | |||
| </li> | |||
| <li> | |||
| <a href="https://x.com/vite_js" target="_blank"> | |||
| <svg | |||
| className="button-icon" | |||
| role="presentation" | |||
| aria-hidden="true" | |||
| > | |||
| <use href="/icons.svg#x-icon"></use> | |||
| </svg> | |||
| X.com | |||
| </a> | |||
| </li> | |||
| <li> | |||
| <a href="https://bsky.app/profile/vite.dev" target="_blank"> | |||
| <svg | |||
| className="button-icon" | |||
| role="presentation" | |||
| aria-hidden="true" | |||
| > | |||
| <use href="/icons.svg#bluesky-icon"></use> | |||
| </svg> | |||
| Bluesky | |||
| </a> | |||
| </li> | |||
| </ul> | |||
| </div> | |||
| </section> | |||
| <div className="ticks"></div> | |||
| <section id="spacer"></section> | |||
| </> | |||
| <ConfigProvider | |||
| theme={{ | |||
| algorithm: theme.compactAlgorithm, | |||
| token: workspaceThemeTokens, | |||
| components: { | |||
| Layout: { | |||
| headerBg: '#FFFFFF', | |||
| siderBg: '#FFFFFF', | |||
| }, | |||
| Table: { | |||
| cellPaddingBlockSM: 6, | |||
| cellPaddingInlineSM: 8, | |||
| }, | |||
| }, | |||
| }} | |||
| > | |||
| <div className="app-shell"> | |||
| <WorkspaceShell /> | |||
| </div> | |||
| </ConfigProvider> | |||
| ) | |||
| } | |||
| @@ -1,111 +1,23 @@ | |||
| :root { | |||
| --text: #6b6375; | |||
| --text-h: #08060d; | |||
| --bg: #fff; | |||
| --border: #e5e4e7; | |||
| --code-bg: #f4f3ec; | |||
| --accent: #aa3bff; | |||
| --accent-bg: rgba(170, 59, 255, 0.1); | |||
| --accent-border: rgba(170, 59, 255, 0.5); | |||
| --social-bg: rgba(244, 243, 236, 0.5); | |||
| --shadow: | |||
| rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; | |||
| --sans: system-ui, 'Segoe UI', Roboto, sans-serif; | |||
| --heading: system-ui, 'Segoe UI', Roboto, sans-serif; | |||
| --mono: ui-monospace, Consolas, monospace; | |||
| font: 18px/145% var(--sans); | |||
| letter-spacing: 0.18px; | |||
| color-scheme: light dark; | |||
| color: var(--text); | |||
| background: var(--bg); | |||
| color: #111827; | |||
| background: #f7f9fc; | |||
| font-family: 'Public Sans', 'Segoe UI', Arial, sans-serif; | |||
| font-synthesis: none; | |||
| line-height: 1.5; | |||
| text-rendering: optimizeLegibility; | |||
| -webkit-font-smoothing: antialiased; | |||
| -moz-osx-font-smoothing: grayscale; | |||
| @media (max-width: 1024px) { | |||
| font-size: 16px; | |||
| } | |||
| } | |||
| @media (prefers-color-scheme: dark) { | |||
| :root { | |||
| --text: #9ca3af; | |||
| --text-h: #f3f4f6; | |||
| --bg: #16171d; | |||
| --border: #2e303a; | |||
| --code-bg: #1f2028; | |||
| --accent: #c084fc; | |||
| --accent-bg: rgba(192, 132, 252, 0.15); | |||
| --accent-border: rgba(192, 132, 252, 0.5); | |||
| --social-bg: rgba(47, 48, 58, 0.5); | |||
| --shadow: | |||
| rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; | |||
| } | |||
| #social .button-icon { | |||
| filter: invert(1) brightness(2); | |||
| } | |||
| } | |||
| #root { | |||
| width: 1126px; | |||
| max-width: 100%; | |||
| margin: 0 auto; | |||
| text-align: center; | |||
| border-inline: 1px solid var(--border); | |||
| min-height: 100svh; | |||
| display: flex; | |||
| flex-direction: column; | |||
| * { | |||
| box-sizing: border-box; | |||
| } | |||
| body { | |||
| margin: 0; | |||
| min-width: 320px; | |||
| } | |||
| h1, | |||
| h2 { | |||
| font-family: var(--heading); | |||
| font-weight: 500; | |||
| color: var(--text-h); | |||
| } | |||
| h1 { | |||
| font-size: 56px; | |||
| letter-spacing: -1.68px; | |||
| margin: 32px 0; | |||
| @media (max-width: 1024px) { | |||
| font-size: 36px; | |||
| margin: 20px 0; | |||
| } | |||
| } | |||
| h2 { | |||
| font-size: 24px; | |||
| line-height: 118%; | |||
| letter-spacing: -0.24px; | |||
| margin: 0 0 8px; | |||
| @media (max-width: 1024px) { | |||
| font-size: 20px; | |||
| } | |||
| } | |||
| p { | |||
| margin: 0; | |||
| } | |||
| code, | |||
| .counter { | |||
| font-family: var(--mono); | |||
| display: inline-flex; | |||
| border-radius: 4px; | |||
| color: var(--text-h); | |||
| } | |||
| code { | |||
| font-size: 15px; | |||
| line-height: 135%; | |||
| padding: 4px 8px; | |||
| background: var(--code-bg); | |||
| #root { | |||
| min-height: 100vh; | |||
| } | |||
| @@ -1,5 +1,6 @@ | |||
| import { StrictMode } from 'react' | |||
| import { createRoot } from 'react-dom/client' | |||
| import 'antd/dist/reset.css' | |||
| import './index.css' | |||
| import App from './App.tsx' | |||
| @@ -0,0 +1,179 @@ | |||
| .workspace-shell { | |||
| min-height: 100vh; | |||
| background: #f7f9fc; | |||
| color: #111827; | |||
| } | |||
| .workspace-shell :where(button, a, input, textarea, select, [tabindex]):focus-visible { | |||
| outline: 2px solid var(--workspace-focus); | |||
| outline-offset: 2px; | |||
| } | |||
| .workspace-nav { | |||
| border-right: 1px solid var(--workspace-border); | |||
| overflow: auto; | |||
| } | |||
| .workspace-brand { | |||
| padding: 16px 16px 12px; | |||
| border-bottom: 1px solid var(--workspace-border); | |||
| } | |||
| .workspace-brand h1, | |||
| .workspace-inspector h2, | |||
| .workspace-board h2 { | |||
| margin: 0; | |||
| } | |||
| .workspace-brand h1 { | |||
| font-size: 22px; | |||
| line-height: 30px; | |||
| } | |||
| .workspace-brand__eyebrow, | |||
| .workspace-kicker { | |||
| color: var(--workspace-secondary); | |||
| display: block; | |||
| font-size: 12px; | |||
| font-weight: 700; | |||
| letter-spacing: 0; | |||
| text-transform: uppercase; | |||
| } | |||
| .workspace-main { | |||
| min-width: 0; | |||
| } | |||
| .workspace-header { | |||
| align-items: center; | |||
| border-bottom: 1px solid var(--workspace-border); | |||
| display: flex; | |||
| height: 48px; | |||
| justify-content: space-between; | |||
| padding: 0 16px; | |||
| } | |||
| .workspace-content { | |||
| overflow: auto; | |||
| padding: 16px; | |||
| } | |||
| .workspace-support-notice { | |||
| margin-bottom: 12px; | |||
| } | |||
| .workspace-board { | |||
| background: var(--workspace-surface); | |||
| border: 1px solid var(--workspace-border); | |||
| border-radius: 4px; | |||
| min-width: 0; | |||
| } | |||
| .workspace-board__header, | |||
| .workspace-board__footer { | |||
| align-items: center; | |||
| display: flex; | |||
| gap: 16px; | |||
| justify-content: space-between; | |||
| padding: 12px 16px; | |||
| } | |||
| .workspace-board__header { | |||
| border-bottom: 1px solid var(--workspace-border); | |||
| } | |||
| .workspace-board__footer { | |||
| border-top: 1px solid var(--workspace-border); | |||
| } | |||
| .workspace-table .ant-table { | |||
| border-radius: 0; | |||
| } | |||
| .workspace-status { | |||
| align-items: center; | |||
| background: #ffffff; | |||
| display: inline-flex; | |||
| gap: 5px; | |||
| line-height: 20px; | |||
| margin-inline-end: 0; | |||
| } | |||
| .workspace-row--blocked > td { | |||
| background: #fff7f7; | |||
| } | |||
| .workspace-inspector { | |||
| border-left: 1px solid var(--workspace-border); | |||
| overflow: auto; | |||
| padding: 12px; | |||
| } | |||
| .workspace-inspector--rail { | |||
| align-items: start; | |||
| display: flex; | |||
| justify-content: center; | |||
| padding: 12px 4px; | |||
| } | |||
| .workspace-inspector__header { | |||
| align-items: start; | |||
| display: flex; | |||
| gap: 8px; | |||
| justify-content: space-between; | |||
| margin-bottom: 12px; | |||
| } | |||
| .workspace-risk-list { | |||
| width: 100%; | |||
| } | |||
| .workspace-risk-item { | |||
| border: 1px solid var(--workspace-border); | |||
| border-radius: 4px; | |||
| display: grid; | |||
| gap: 8px; | |||
| padding: 10px; | |||
| } | |||
| .workspace-risk-item__id { | |||
| color: var(--workspace-text-secondary); | |||
| display: block; | |||
| font-family: 'IBM Plex Mono', Consolas, monospace; | |||
| font-size: 12px; | |||
| } | |||
| @media (min-width: 1280px) and (max-width: 1599px) { | |||
| .workspace-inspector:not(.workspace-inspector--rail) { | |||
| max-width: 288px; | |||
| } | |||
| .workspace-content { | |||
| padding: 12px; | |||
| } | |||
| } | |||
| @media (max-width: 1279px) { | |||
| .workspace-nav, | |||
| .workspace-inspector { | |||
| display: none; | |||
| } | |||
| .workspace-header { | |||
| align-items: start; | |||
| flex-direction: column; | |||
| gap: 8px; | |||
| height: auto; | |||
| padding: 12px; | |||
| } | |||
| .workspace-content { | |||
| padding: 12px; | |||
| } | |||
| .workspace-board__header, | |||
| .workspace-board__footer { | |||
| align-items: start; | |||
| flex-direction: column; | |||
| } | |||
| } | |||
| @@ -0,0 +1,374 @@ | |||
| import { | |||
| CheckCircleFilled, | |||
| ClockCircleFilled, | |||
| CloseCircleFilled, | |||
| ExclamationCircleFilled, | |||
| InfoCircleFilled, | |||
| MenuFoldOutlined, | |||
| MenuUnfoldOutlined, | |||
| } from '@ant-design/icons' | |||
| import { | |||
| Alert, | |||
| Badge, | |||
| Button, | |||
| Layout, | |||
| Menu, | |||
| Space, | |||
| Table, | |||
| Tag, | |||
| Tooltip, | |||
| Typography, | |||
| theme, | |||
| type TableProps, | |||
| } from 'antd' | |||
| import { useEffect, useState, type CSSProperties } from 'react' | |||
| import { | |||
| isEditingAvailable, | |||
| isRightPanelCollapsible, | |||
| semanticStatusColors, | |||
| statusDefinitions, | |||
| workspaceThemeTokens, | |||
| type WorkspaceStatus, | |||
| } from './workspaceContracts' | |||
| import './WorkspaceShell.css' | |||
| const { Header, Sider, Content } = Layout | |||
| const { Text, Title } = Typography | |||
| type CycleRow = { | |||
| key: string | |||
| municipality: string | |||
| cycle: string | |||
| service: string | |||
| status: WorkspaceStatus | |||
| dueDate: string | |||
| owner: string | |||
| } | |||
| const cycleRows: CycleRow[] = [ | |||
| { | |||
| key: 'CAM-1042', | |||
| municipality: 'Fairview Borough', | |||
| cycle: 'Primary 2026', | |||
| service: 'Addressing', | |||
| status: 'onTrack', | |||
| dueDate: 'May 18', | |||
| owner: 'Client Services', | |||
| }, | |||
| { | |||
| key: 'CAM-1088', | |||
| municipality: 'Lake Township', | |||
| cycle: 'Primary 2026', | |||
| service: 'Transport', | |||
| status: 'atRisk', | |||
| dueDate: 'May 12', | |||
| owner: 'Production Lead', | |||
| }, | |||
| { | |||
| key: 'CAM-1120', | |||
| municipality: 'Northfield City', | |||
| cycle: 'Special 2026', | |||
| service: 'Sorting', | |||
| status: 'blocked', | |||
| dueDate: 'May 9', | |||
| owner: 'Data Quality', | |||
| }, | |||
| { | |||
| key: 'CAM-1137', | |||
| municipality: 'Pine County', | |||
| cycle: 'General 2026', | |||
| service: 'Office Copies', | |||
| status: 'inProgress', | |||
| dueDate: 'May 22', | |||
| owner: 'Operations', | |||
| }, | |||
| ] | |||
| const riskItems = [ | |||
| { | |||
| id: 'RQ-18', | |||
| title: 'Lake Township transport confirmation', | |||
| status: 'atRisk' as WorkspaceStatus, | |||
| owner: 'Production Lead', | |||
| }, | |||
| { | |||
| id: 'RQ-21', | |||
| title: 'Northfield missing sorting owner', | |||
| status: 'blocked' as WorkspaceStatus, | |||
| owner: 'Data Quality', | |||
| }, | |||
| { | |||
| id: 'RQ-24', | |||
| title: 'Pine County proof approval check', | |||
| status: 'inProgress' as WorkspaceStatus, | |||
| owner: 'Operations', | |||
| }, | |||
| ] | |||
| const statusIcons = { | |||
| onTrack: CheckCircleFilled, | |||
| atRisk: ExclamationCircleFilled, | |||
| blocked: CloseCircleFilled, | |||
| inProgress: InfoCircleFilled, | |||
| overdue: ClockCircleFilled, | |||
| } satisfies Record<WorkspaceStatus, typeof CheckCircleFilled> | |||
| function useViewportWidth() { | |||
| const [width, setWidth] = useState(() => | |||
| typeof window === 'undefined' ? 1600 : window.innerWidth, | |||
| ) | |||
| useEffect(() => { | |||
| const updateWidth = () => setWidth(window.innerWidth) | |||
| updateWidth() | |||
| window.addEventListener('resize', updateWidth) | |||
| return () => window.removeEventListener('resize', updateWidth) | |||
| }, []) | |||
| return width | |||
| } | |||
| function StatusIndicator({ status }: { status: WorkspaceStatus }) { | |||
| const definition = statusDefinitions[status] | |||
| const Icon = statusIcons[status] | |||
| return ( | |||
| <Tag | |||
| className="workspace-status" | |||
| style={{ | |||
| borderColor: definition.color, | |||
| color: definition.color, | |||
| }} | |||
| aria-label={`${definition.iconLabel}: ${definition.label}`} | |||
| > | |||
| <Icon aria-hidden="true" /> | |||
| <span>{definition.label}</span> | |||
| </Tag> | |||
| ) | |||
| } | |||
| function WorkspaceNavigation() { | |||
| return ( | |||
| <Sider width={248} className="workspace-nav" theme="light"> | |||
| <div className="workspace-brand"> | |||
| <Text className="workspace-brand__eyebrow">Campaign Tracker</Text> | |||
| <Title level={1}>Operations</Title> | |||
| </div> | |||
| <Menu | |||
| mode="inline" | |||
| defaultSelectedKeys={['cycles']} | |||
| items={[ | |||
| { key: 'cycles', label: 'Election Cycles' }, | |||
| { key: 'municipalities', label: 'Municipalities' }, | |||
| { key: 'milestones', label: 'Milestones' }, | |||
| { key: 'reports', label: 'Reports' }, | |||
| ]} | |||
| /> | |||
| </Sider> | |||
| ) | |||
| } | |||
| function RiskPanel({ | |||
| collapsed, | |||
| onToggle, | |||
| canCollapse, | |||
| }: { | |||
| collapsed: boolean | |||
| onToggle: () => void | |||
| canCollapse: boolean | |||
| }) { | |||
| if (collapsed) { | |||
| return ( | |||
| <Sider width={48} className="workspace-inspector workspace-inspector--rail"> | |||
| <Tooltip title="Open risk panel" placement="left"> | |||
| <Button | |||
| type="text" | |||
| icon={<MenuUnfoldOutlined />} | |||
| aria-label="Open risk panel" | |||
| onClick={onToggle} | |||
| /> | |||
| </Tooltip> | |||
| </Sider> | |||
| ) | |||
| } | |||
| return ( | |||
| <Sider width={336} className="workspace-inspector" theme="light"> | |||
| <div className="workspace-inspector__header"> | |||
| <div> | |||
| <Text className="workspace-kicker">Risk queue</Text> | |||
| <Title level={2}>Cutoff Watch</Title> | |||
| </div> | |||
| {canCollapse ? ( | |||
| <Tooltip title="Collapse risk panel" placement="left"> | |||
| <Button | |||
| type="text" | |||
| icon={<MenuFoldOutlined />} | |||
| aria-label="Collapse risk panel" | |||
| onClick={onToggle} | |||
| /> | |||
| </Tooltip> | |||
| ) : null} | |||
| </div> | |||
| <Space direction="vertical" size={8} className="workspace-risk-list"> | |||
| {riskItems.map((item) => ( | |||
| <article className="workspace-risk-item" key={item.id}> | |||
| <div> | |||
| <Text className="workspace-risk-item__id">{item.id}</Text> | |||
| <Text strong>{item.title}</Text> | |||
| </div> | |||
| <StatusIndicator status={item.status} /> | |||
| <Text type="secondary">{item.owner}</Text> | |||
| </article> | |||
| ))} | |||
| </Space> | |||
| </Sider> | |||
| ) | |||
| } | |||
| export function WorkspaceShell() { | |||
| const width = useViewportWidth() | |||
| const editingAvailable = isEditingAvailable(width) | |||
| const canCollapseRightPanel = isRightPanelCollapsible(width) | |||
| const [rightPanelCollapseRequested, setRightPanelCollapseRequested] = | |||
| useState(false) | |||
| const rightPanelCollapsed = | |||
| canCollapseRightPanel && rightPanelCollapseRequested | |||
| const { token } = theme.useToken() | |||
| const columns: TableProps<CycleRow>['columns'] = [ | |||
| { | |||
| title: 'Record', | |||
| dataIndex: 'key', | |||
| key: 'key', | |||
| render: (value: string) => <Text code>{value}</Text>, | |||
| }, | |||
| { | |||
| title: 'Municipality', | |||
| dataIndex: 'municipality', | |||
| key: 'municipality', | |||
| }, | |||
| { | |||
| title: 'Cycle', | |||
| dataIndex: 'cycle', | |||
| key: 'cycle', | |||
| }, | |||
| { | |||
| title: 'Service', | |||
| dataIndex: 'service', | |||
| key: 'service', | |||
| }, | |||
| { | |||
| title: 'Status', | |||
| dataIndex: 'status', | |||
| key: 'status', | |||
| render: (status: WorkspaceStatus) => <StatusIndicator status={status} />, | |||
| }, | |||
| { | |||
| title: 'Due', | |||
| dataIndex: 'dueDate', | |||
| key: 'dueDate', | |||
| }, | |||
| { | |||
| title: 'Owner', | |||
| dataIndex: 'owner', | |||
| key: 'owner', | |||
| }, | |||
| ] | |||
| return ( | |||
| <Layout | |||
| className="workspace-shell" | |||
| style={ | |||
| { | |||
| '--workspace-secondary': semanticStatusColors.secondary, | |||
| '--workspace-focus': workspaceThemeTokens.colorInfo, | |||
| '--workspace-border': workspaceThemeTokens.colorBorder, | |||
| '--workspace-surface': '#FFFFFF', | |||
| '--workspace-text-secondary': workspaceThemeTokens.colorTextSecondary, | |||
| } as CSSProperties | |||
| } | |||
| > | |||
| <WorkspaceNavigation /> | |||
| <Layout className="workspace-main"> | |||
| <Header className="workspace-header"> | |||
| <Space align="center" size={12}> | |||
| <Badge color={workspaceThemeTokens.colorPrimary} text="Primary workspace" /> | |||
| <Text type="secondary">Authenticated operations shell</Text> | |||
| </Space> | |||
| <Space> | |||
| <Button disabled={!editingAvailable}>Save View</Button> | |||
| <Tooltip | |||
| title={ | |||
| editingAvailable | |||
| ? 'Commit selected operational updates' | |||
| : 'Use a 1280px or wider desktop viewport for editing' | |||
| } | |||
| > | |||
| <Button type="primary" disabled={!editingAvailable}> | |||
| Commit Update | |||
| </Button> | |||
| </Tooltip> | |||
| </Space> | |||
| </Header> | |||
| <Content className="workspace-content"> | |||
| {!editingAvailable ? ( | |||
| <Alert | |||
| className="workspace-support-notice" | |||
| type="info" | |||
| showIcon | |||
| message="Reduced read mode" | |||
| description="This viewport is below the 1280px operational minimum. Review is available, but editing and commit actions are disabled until the workspace is opened on a supported desktop width." | |||
| /> | |||
| ) : null} | |||
| <section | |||
| className="workspace-board" | |||
| aria-label="Election cycle operations workspace" | |||
| > | |||
| <div className="workspace-board__header"> | |||
| <div> | |||
| <Text className="workspace-kicker">Election cycle setup</Text> | |||
| <Title level={2}>Municipality work queue</Title> | |||
| </div> | |||
| <Space size={8} wrap> | |||
| <StatusIndicator status="onTrack" /> | |||
| <StatusIndicator status="atRisk" /> | |||
| <StatusIndicator status="blocked" /> | |||
| </Space> | |||
| </div> | |||
| <Table | |||
| className="workspace-table" | |||
| columns={columns} | |||
| dataSource={cycleRows} | |||
| pagination={false} | |||
| size="small" | |||
| scroll={{ x: 960 }} | |||
| rowClassName={(row) => | |||
| row.status === 'blocked' ? 'workspace-row--blocked' : '' | |||
| } | |||
| /> | |||
| <div className="workspace-board__footer"> | |||
| <Text type="secondary"> | |||
| Last refreshed 12:48 PM. Legacy source context remains read-only; | |||
| new workflow updates route through extension records. | |||
| </Text> | |||
| <Button | |||
| style={{ borderColor: token.colorBorder }} | |||
| disabled={!editingAvailable} | |||
| > | |||
| Open Inspector | |||
| </Button> | |||
| </div> | |||
| </section> | |||
| </Content> | |||
| </Layout> | |||
| <RiskPanel | |||
| collapsed={rightPanelCollapsed} | |||
| canCollapse={canCollapseRightPanel} | |||
| onToggle={() => setRightPanelCollapseRequested((value) => !value)} | |||
| /> | |||
| </Layout> | |||
| ) | |||
| } | |||
| @@ -0,0 +1,52 @@ | |||
| import { describe, expect, it } from 'vitest' | |||
| import { | |||
| isEditingAvailable, | |||
| isRightPanelCollapsible, | |||
| semanticStatusColors, | |||
| statusDefinitions, | |||
| workspaceDensityProfile, | |||
| workspaceThemeTokens, | |||
| getWorkspaceViewportMode, | |||
| } from './workspaceContracts' | |||
| describe('workspace visual foundation contracts', () => { | |||
| it('uses the approved Ant Design token palette and compact density profile', () => { | |||
| expect(workspaceThemeTokens.colorPrimary).toBe('#1F4E79') | |||
| expect(workspaceThemeTokens.colorSuccess).toBe('#2E7D32') | |||
| expect(workspaceThemeTokens.colorWarning).toBe('#B45309') | |||
| expect(workspaceThemeTokens.colorError).toBe('#B91C1C') | |||
| expect(workspaceThemeTokens.colorInfo).toBe('#2563EB') | |||
| expect(semanticStatusColors.secondary).toBe('#0F766E') | |||
| expect(semanticStatusColors.overdue).toBe('#7F1D1D') | |||
| expect(workspaceDensityProfile.compact).toBe(true) | |||
| expect(workspaceDensityProfile.controlHeight).toBeLessThanOrEqual(32) | |||
| }) | |||
| it('maps desktop breakpoints to the required layout modes', () => { | |||
| expect(getWorkspaceViewportMode(1279)).toBe('read-reduced') | |||
| expect(getWorkspaceViewportMode(1280)).toBe('compact-tri-pane') | |||
| expect(getWorkspaceViewportMode(1599)).toBe('compact-tri-pane') | |||
| expect(getWorkspaceViewportMode(1600)).toBe('full-tri-pane') | |||
| }) | |||
| it('allows editing only at supported desktop widths', () => { | |||
| expect(isEditingAvailable(1024)).toBe(false) | |||
| expect(isEditingAvailable(1279)).toBe(false) | |||
| expect(isEditingAvailable(1280)).toBe(true) | |||
| }) | |||
| it('keeps the right panel collapsible only in standard desktop compact mode', () => { | |||
| expect(isRightPanelCollapsible(1279)).toBe(false) | |||
| expect(isRightPanelCollapsible(1280)).toBe(true) | |||
| expect(isRightPanelCollapsible(1599)).toBe(true) | |||
| expect(isRightPanelCollapsible(1600)).toBe(false) | |||
| }) | |||
| it('requires every status indicator to have color plus text and icon labels', () => { | |||
| Object.values(statusDefinitions).forEach((status) => { | |||
| expect(status.color).toMatch(/^#[0-9A-F]{6}$/) | |||
| expect(status.label.length).toBeGreaterThan(0) | |||
| expect(status.iconLabel.length).toBeGreaterThan(0) | |||
| }) | |||
| }) | |||
| }) | |||
| @@ -0,0 +1,93 @@ | |||
| export const semanticStatusColors = { | |||
| success: '#2E7D32', | |||
| warning: '#B45309', | |||
| error: '#B91C1C', | |||
| info: '#2563EB', | |||
| overdue: '#7F1D1D', | |||
| secondary: '#0F766E', | |||
| } as const | |||
| export const workspaceThemeTokens = { | |||
| colorPrimary: '#1F4E79', | |||
| colorSuccess: semanticStatusColors.success, | |||
| colorWarning: semanticStatusColors.warning, | |||
| colorError: semanticStatusColors.error, | |||
| colorInfo: semanticStatusColors.info, | |||
| colorBgBase: '#F7F9FC', | |||
| colorText: '#111827', | |||
| colorTextSecondary: '#4B5563', | |||
| colorBorder: '#D0D7E2', | |||
| borderRadius: 4, | |||
| fontFamily: 'Public Sans, Segoe UI, Arial, sans-serif', | |||
| fontSize: 14, | |||
| controlHeight: 28, | |||
| } as const | |||
| export const workspaceDensityProfile = { | |||
| compact: true, | |||
| controlHeight: workspaceThemeTokens.controlHeight, | |||
| baseUnit: 8, | |||
| } as const | |||
| export type WorkspaceViewportMode = | |||
| | 'read-reduced' | |||
| | 'compact-tri-pane' | |||
| | 'full-tri-pane' | |||
| export type WorkspaceStatus = | |||
| | 'onTrack' | |||
| | 'atRisk' | |||
| | 'blocked' | |||
| | 'inProgress' | |||
| | 'overdue' | |||
| export const statusDefinitions: Record< | |||
| WorkspaceStatus, | |||
| { color: string; label: string; iconLabel: string } | |||
| > = { | |||
| onTrack: { | |||
| color: semanticStatusColors.success, | |||
| label: 'On track', | |||
| iconLabel: 'Success status', | |||
| }, | |||
| atRisk: { | |||
| color: semanticStatusColors.warning, | |||
| label: 'At risk', | |||
| iconLabel: 'Warning status', | |||
| }, | |||
| blocked: { | |||
| color: semanticStatusColors.error, | |||
| label: 'Blocked', | |||
| iconLabel: 'Blocked status', | |||
| }, | |||
| inProgress: { | |||
| color: semanticStatusColors.info, | |||
| label: 'In progress', | |||
| iconLabel: 'Progress status', | |||
| }, | |||
| overdue: { | |||
| color: semanticStatusColors.overdue, | |||
| label: 'Overdue', | |||
| iconLabel: 'Critical overdue status', | |||
| }, | |||
| } | |||
| export function getWorkspaceViewportMode(width: number): WorkspaceViewportMode { | |||
| if (width < 1280) { | |||
| return 'read-reduced' | |||
| } | |||
| if (width < 1600) { | |||
| return 'compact-tri-pane' | |||
| } | |||
| return 'full-tri-pane' | |||
| } | |||
| export function isEditingAvailable(width: number) { | |||
| return getWorkspaceViewportMode(width) !== 'read-reduced' | |||
| } | |||
| export function isRightPanelCollapsible(width: number) { | |||
| return getWorkspaceViewportMode(width) === 'compact-tri-pane' | |||
| } | |||
Powered by TurnKey Linux.