| @@ -1,6 +1,6 @@ | |||||
| @Campaign_Tracker.Server_HostAddress = http://localhost:5254 | @Campaign_Tracker.Server_HostAddress = http://localhost:5254 | ||||
| GET {{Campaign_Tracker.Server_HostAddress}}/weatherforecast/ | |||||
| GET {{Campaign_Tracker.Server_HostAddress}}/health | |||||
| Accept: application/json | 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.sln` | ||||
| - `Campaign_Tracker.Server/Campaign_Tracker.Server.csproj` | - `Campaign_Tracker.Server/Campaign_Tracker.Server.csproj` | ||||
| - `Campaign_Tracker.Server/Program.cs` | - `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.json` | ||||
| - `Campaign_Tracker.Server/appsettings.Development.json` | - `Campaign_Tracker.Server/appsettings.Development.json` | ||||
| - `Campaign_Tracker.Server/Properties/launchSettings.json` | - `Campaign_Tracker.Server/Properties/launchSettings.json` | ||||
| @@ -1,6 +1,6 @@ | |||||
| # Story 1.2: Workspace Shell & Ant Design Foundation | # Story 1.2: Workspace Shell & Ant Design Foundation | ||||
| Status: ready-for-dev | |||||
| Status: review | |||||
| ## Story | ## Story | ||||
| @@ -19,18 +19,18 @@ so that I have a predictable, accessible, and keyboard-navigable operational env | |||||
| ## Tasks / Subtasks | ## 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 | ## Dev Notes | ||||
| @@ -59,13 +59,40 @@ GPT-5 Codex | |||||
| ### Debug Log References | ### Debug Log References | ||||
| - Story generated from epic source and architecture/UX planning artifacts. | - 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 | ### Completion Notes List | ||||
| - Story context created and marked ready-for-dev. | - 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 | ### File List | ||||
| - `_bmad-output/implementation-artifacts/sprint-status.yaml` | |||||
| - `_bmad-output/implementation-artifacts/1-2-workspace-shell-ant-design-foundation.md` | - `_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) | # - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended) | ||||
| generated: '2026-05-05T12:00:44-04:00' | 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: 'Campaign_Tracker App' | ||||
| project_key: 'NOKEY' | project_key: 'NOKEY' | ||||
| tracking_system: 'file-system' | tracking_system: 'file-system' | ||||
| @@ -44,7 +44,7 @@ story_location: '_bmad-output/implementation-artifacts' | |||||
| development_status: | development_status: | ||||
| epic-1: in-progress | epic-1: in-progress | ||||
| 1-1-project-initialization-solution-scaffold: review | 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-3-keycloak-realm-configuration-oidc-integration: ready-for-dev | ||||
| 1-4-keycloak-role-mapping-application-authorization: ready-for-dev | 1-4-keycloak-role-mapping-application-authorization: ready-for-dev | ||||
| 1-5-shared-audit-logging-infrastructure: ready-for-dev | 1-5-shared-audit-logging-infrastructure: ready-for-dev | ||||
| @@ -7,9 +7,12 @@ | |||||
| "dev": "vite", | "dev": "vite", | ||||
| "build": "tsc -b && vite build", | "build": "tsc -b && vite build", | ||||
| "lint": "eslint .", | "lint": "eslint .", | ||||
| "test": "vitest run", | |||||
| "preview": "vite preview" | "preview": "vite preview" | ||||
| }, | }, | ||||
| "dependencies": { | "dependencies": { | ||||
| "@ant-design/icons": "^5.6.1", | |||||
| "antd": "^5.29.3", | |||||
| "react": "^19.2.5", | "react": "^19.2.5", | ||||
| "react-dom": "^19.2.5" | "react-dom": "^19.2.5" | ||||
| }, | }, | ||||
| @@ -25,7 +28,7 @@ | |||||
| "globals": "^17.5.0", | "globals": "^17.5.0", | ||||
| "typescript": "~6.0.2", | "typescript": "~6.0.2", | ||||
| "typescript-eslint": "^8.58.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 './App.css' | ||||
| import { WorkspaceShell } from './workspace/WorkspaceShell' | |||||
| import { workspaceThemeTokens } from './workspace/workspaceContracts' | |||||
| function App() { | function App() { | ||||
| const [count, setCount] = useState(0) | |||||
| return ( | 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 { | :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; | font-synthesis: none; | ||||
| line-height: 1.5; | |||||
| text-rendering: optimizeLegibility; | text-rendering: optimizeLegibility; | ||||
| -webkit-font-smoothing: antialiased; | -webkit-font-smoothing: antialiased; | ||||
| -moz-osx-font-smoothing: grayscale; | -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; | box-sizing: border-box; | ||||
| } | } | ||||
| body { | body { | ||||
| margin: 0; | 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 { StrictMode } from 'react' | ||||
| import { createRoot } from 'react-dom/client' | import { createRoot } from 'react-dom/client' | ||||
| import 'antd/dist/reset.css' | |||||
| import './index.css' | import './index.css' | ||||
| import App from './App.tsx' | 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.