Compare commits

13 Commits

Author SHA1 Message Date
Sylvain Schneider
a8f66f08b0 UI update 2026-05-22 17:25:23 +02:00
Sylvain Schneider
f736e6519b fixed user interface rendering with an embedded font and client-independent settings 2026-05-22 11:01:43 +02:00
Sylvain Schneider
7f4cfd0ea9 update 2026-05-22 10:49:24 +02:00
Sylvain Schneider
da85df11f7 appends requirement edition mode 2026-05-12 00:51:28 +02:00
Sylvain Schneider
8aa3128edf gui changes 2026-05-12 00:23:49 +02:00
Sylvain Schneider
d4248775b9 add toolbar and statusbar 2026-05-11 23:51:24 +02:00
Sylvain Schneider
e9e334cdcb appends application menubar 2026-05-11 23:46:22 +02:00
Sylvain Schneider
4e179b40d2 appends tests tab 2026-05-11 23:39:20 +02:00
Sylvain Schneider
14314de3b8 add fields for requirement 2026-05-11 23:32:51 +02:00
Sylvain Schneider
b742a9a59e update ui sizes and distances 2026-05-11 23:20:59 +02:00
Sylvain Schneider
e73ba429e7 create a requirements store 2026-05-11 23:14:20 +02:00
Sylvain Schneider
a07d217953 works on requirements tree list 2026-05-11 23:09:30 +02:00
Sylvain Schneider
a5a28f231c prepare basic views and components 2026-05-11 22:51:57 +02:00
30 changed files with 2808 additions and 485 deletions

View File

@@ -2,6 +2,92 @@
This template should help get you started developing with Vue 3 in Vite.
## Backend JSON Bridge
The UI exposes a JSON bridge intended for integration inside a native host such as a C++ application.
### Available bridge entry points
- `window.kwaUiBridge.receiveFromBackend(message)` lets the host inject a JSON string or already parsed message object into the UI.
- `window.kwaUiBridge.sendRequest(topic, payload)` sends a `requestMessage` to the host and resolves with the matching `responseMessage.payload`.
- `window.kwaUiBridge.emitEvent(topic, payload)` sends a one-way `eventMessage` to the host.
- Outgoing messages are also mirrored as a browser event named `kwa-ui:outgoing-message` whose `detail` contains the serialized JSON.
- Incoming messages can also be injected by dispatching `kwa-ui:incoming-message` with the raw JSON string in `detail`.
### Message envelopes
Every message is JSON and uses one of the following envelope shapes.
```json
{
"kind": "requestMessage",
"id": "3f4f58aa-a49f-49b1-a43a-3ca3bff8469f",
"timestamp": "2026-05-22T15:28:00.000Z"
"topic": "requirements.list",
"payload": {},
}
```
```json
{
"kind": "responseMessage",
"id": "7e7e0cba-d4b8-4d8f-a0cb-31f4f3110db5",
"requestId": "3f4f58aa-a49f-49b1-a43a-3ca3bff8469f",
"timestamp": "2026-05-22T15:28:00.010Z"
"topic": "requirements.list",
"ok": true,
"payload": {
"requirements": []
},
}
```
```json
{
"kind": "eventMessage",
"id": "94f2b4d4-6f16-4f14-8dc7-869f6d3e1a55",
"timestamp": "2026-05-22T15:28:00.020Z"
"topic": "requirements.updated",
"payload": {
"requirement": {
"id": 101
}
},
}
```
### Native host transport
The bridge will send outgoing JSON to the first available transport below:
- `window.kwaBackendHost.postMessage(string)`
- `window.chrome.webview.postMessage(string)`
- `window.webkit.messageHandlers.kwaUiBridge.postMessage(string)`
If none of these are available, the UI still emits `kwa-ui:outgoing-message`, which is useful for testing or for a wrapper script.
### Registered topics
- `requirements.list`: request the full requirements collection and current selection.
- `requirements.get`: request payload `{ "id": number }`, returns one requirement or `null`.
- `requirements.select`: request or event payload `{ "id": number }`, selects the matching requirement.
- `requirements.update`: request payload `{ "requirement": { ... } }`, updates a requirement and returns the saved value.
- `requirements.replaceAll`: event payload `{ "requirements": [ ... ] }`, replaces the current collection in the UI.
### Events pushed by the backend to the UI
The backend can push an `eventMessage` at any time by calling `window.kwaUiBridge.receiveFromBackend(json)`. The following topics are handled:
- `requirements.replaceAll`: event payload `{ "requirements": [ ... ] }`, replaces the full requirements collection in the UI.
- `requirements.select`: event payload `{ "id": number }`, selects the matching requirement in the UI.
### Events emitted by the UI
- `ui.ready`: emitted once the bridge is installed.
- `requirements.selectionChanged`: emitted whenever the selected requirement changes.
- `requirements.updated`: emitted after a requirement update initiated in the UI.
- `requirements.replaced`: emitted after the full requirements collection is replaced.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).

29
kwa-ui/env.d.ts vendored
View File

@@ -1 +1,30 @@
/// <reference types="vite/client" />
import type { BridgeMessage, UiBridgeApi } from './src/bridge/backendBridge'
declare global {
interface Window {
kwaBackendHost?: {
postMessage: (message: string) => void
}
kwaBridgeConfig?: {
webkitHandlerNames?: string[]
}
kwaUiBridge?: UiBridgeApi
chrome?: {
webview?: {
postMessage: (message: string) => void
}
}
webkit?: {
messageHandlers?: Record<string, { postMessage: (message: string) => void }>
}
}
interface WindowEventMap {
'kwa-ui:incoming-message': CustomEvent<string | BridgeMessage>
'kwa-ui:outgoing-message': CustomEvent<string>
}
}
export {}

View File

@@ -1,8 +1,9 @@
<!DOCTYPE html>
<html lang="">
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="color-scheme" content="light">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>

View File

@@ -11,6 +11,7 @@
"type-check": "vue-tsc --build"
},
"dependencies": {
"@fontsource/source-sans-3": "^5.2.9",
"@primeuix/themes": "^2.0.3",
"pinia": "^3.0.4",
"primeicons": "^7.0.0",

View File

@@ -1,85 +1,66 @@
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router'
import HelloWorld from './components/HelloWorld.vue'
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AppMenubar from './components/AppMenubar.vue'
import AppToolbar from './components/AppToolbar.vue'
import AppStatusbar from './components/AppStatusbar.vue'
const route = useRoute()
const router = useRouter()
const activeView = computed<'home' | 'requirements'>(() => {
return route.name === 'home' ? 'home' : 'requirements'
})
const goHome = () => {
router.push({ name: 'home' })
}
const goRequirements = () => {
router.push({ name: 'requirements' })
}
</script>
<template>
<header>
<img alt="Vue logo" class="logo" src="@/assets/logo.svg" width="125" height="125" />
<div class="wrapper">
<HelloWorld msg="You did it!" />
<nav>
<RouterLink to="/">Home</RouterLink>
<RouterLink to="/about">About</RouterLink>
</nav>
</div>
</header>
<RouterView />
<div id="app">
<AppMenubar
:active-view="activeView"
@home="goHome"
@requirements="goRequirements"
/>
<AppToolbar
:active-view="activeView"
@home="goHome"
@requirements="goRequirements"
/>
<main class="app-main">
<RouterView />
</main>
<AppStatusbar />
</div>
</template>
<style scoped>
header {
line-height: 1.5;
max-height: 100vh;
<style>
* {
box-sizing: border-box;
}
.logo {
display: block;
margin: 0 auto 2rem;
html,
body {
height: 100%;
margin: 0;
padding: 0;
}
nav {
width: 100%;
font-size: 12px;
text-align: center;
margin-top: 2rem;
#app {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
nav a.router-link-exact-active {
color: var(--color-text);
}
nav a.router-link-exact-active:hover {
background-color: transparent;
}
nav a {
display: inline-block;
padding: 0 1rem;
border-left: 1px solid var(--color-border);
}
nav a:first-of-type {
border: 0;
}
@media (min-width: 1024px) {
header {
display: flex;
place-items: center;
padding-right: calc(var(--section-gap) / 2);
}
.logo {
margin: 0 2rem 0 0;
}
header .wrapper {
display: flex;
place-items: flex-start;
flex-wrap: wrap;
}
nav {
text-align: left;
margin-left: -1rem;
font-size: 1rem;
padding: 1rem 0;
margin-top: 1rem;
}
.app-main {
flex: 1;
overflow: auto;
}
</style>

View File

@@ -1,53 +1,20 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
color-scheme: light;
color: #12213a;
background-color: #eef3fa;
font-family: 'Source Sans 3', sans-serif;
font-size: 15px;
line-height: 1.6;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
html {
min-height: 100%;
background-color: #eef3fa;
-webkit-text-size-adjust: 100%;
}
*,
@@ -60,27 +27,13 @@
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: inherit;
background: inherit;
}
button,
input,
select,
textarea {
font: inherit;
}

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

Before

Width:  |  Height:  |  Size: 276 B

View File

@@ -1,35 +1,5 @@
@import './base.css';
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
font-weight: normal;
}
a,
.green {
text-decoration: none;
color: hsla(160, 100%, 37%, 1);
transition: 0.4s;
padding: 3px;
}
@media (hover: hover) {
a:hover {
background-color: hsla(160, 100%, 37%, 0.2);
}
}
@media (min-width: 1024px) {
body {
display: flex;
place-items: center;
}
#app {
display: grid;
grid-template-columns: 1fr 1fr;
padding: 0 2rem;
}
min-height: 100vh;
}

View File

@@ -0,0 +1,394 @@
const OUTGOING_EVENT_NAME = 'kwa-ui:outgoing-message'
const INCOMING_EVENT_NAME = 'kwa-ui:incoming-message'
const DEFAULT_REQUEST_TIMEOUT_MS = 10_000
export type BridgeMessageKind = 'requestMessage' | 'responseMessage' | 'eventMessage'
export interface BridgeMessageBase<TKind extends BridgeMessageKind, TPayload = unknown> {
kind: TKind
id: string
timestamp: string
topic: string
payload: TPayload
}
export interface RequestMessage<TPayload = unknown>
extends BridgeMessageBase<'requestMessage', TPayload> {}
export interface ResponseMessage<TPayload = unknown>
extends BridgeMessageBase<'responseMessage', TPayload> {
requestId: string
ok: boolean
error?: string
}
export interface EventMessage<TPayload = unknown>
extends BridgeMessageBase<'eventMessage', TPayload> {}
export type BridgeMessage<TPayload = unknown> =
| RequestMessage<TPayload>
| ResponseMessage<TPayload>
| EventMessage<TPayload>
export interface RequestOptions {
timeoutMs?: number
}
export interface UiBridgeApi {
receiveFromBackend: (message: string | BridgeMessage) => boolean
sendRequest: <TResponse = unknown, TPayload = unknown>(
topic: string,
payload: TPayload,
options?: RequestOptions,
) => Promise<TResponse>
emitEvent: <TPayload = unknown>(topic: string, payload: TPayload) => void
}
type RequestHandler<TPayload = unknown, TResponse = unknown> = (
message: RequestMessage<TPayload>,
) => Promise<TResponse> | TResponse
type EventHandler<TPayload = unknown> = (
message: EventMessage<TPayload>,
) => Promise<void> | void
interface PendingRequest {
resolve: (payload: unknown) => void
reject: (reason?: unknown) => void
timeoutHandle: ReturnType<typeof window.setTimeout>
}
interface OutboundHost {
postMessage: (message: string) => void
}
interface WebkitMessageHandler {
postMessage: (message: string) => void
}
const DEFAULT_WEBKIT_HANDLER_NAMES = ['ui_bridge_handler'] as const
const isRecord = (value: unknown): value is Record<string, unknown> => {
return typeof value === 'object' && value !== null
}
const isBridgeMessageKind = (value: unknown): value is BridgeMessageKind => {
return value === 'requestMessage' || value === 'responseMessage' || value === 'eventMessage'
}
const isBridgeMessage = (value: unknown): value is BridgeMessage => {
if (!isRecord(value)) {
return false
}
if (!isBridgeMessageKind(value.kind)) {
return false
}
return (
typeof value.id === 'string' &&
typeof value.topic === 'string' &&
typeof value.timestamp === 'string' &&
'payload' in value
)
}
const parseBridgeMessage = (value: string | BridgeMessage | unknown): BridgeMessage | null => {
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value) as unknown
return isBridgeMessage(parsed) ? parsed : null
} catch {
return null
}
}
return isBridgeMessage(value) ? value : null
}
const createMessageId = () => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return `msg-${Date.now()}-${Math.random().toString(16).slice(2)}`
}
const normalizeError = (error: unknown) => {
if (error instanceof Error) {
return error.message
}
return typeof error === 'string' ? error : 'Unknown bridge error'
}
const isWebkitMessageHandler = (value: unknown): value is WebkitMessageHandler => {
return isRecord(value) && typeof value.postMessage === 'function'
}
const resolveWebkitHandlers = (): OutboundHost[] => {
const messageHandlers = window.webkit?.messageHandlers
if (!isRecord(messageHandlers)) {
return []
}
const handlers: OutboundHost[] = []
for (const handlerName of DEFAULT_WEBKIT_HANDLER_NAMES) {
const handler = (messageHandlers as Record<string, unknown>)[handlerName]
if (!isWebkitMessageHandler(handler)) {
continue
}
handlers.push({
postMessage: (message: string) => handler.postMessage(message),
})
}
return handlers
}
const resolveOutboundHosts = (): OutboundHost[] => {
const hosts: OutboundHost[] = []
if (window.kwaBackendHost?.postMessage) {
hosts.push(window.kwaBackendHost)
}
if (window.chrome?.webview?.postMessage) {
hosts.push({
postMessage: (message: string) => window.chrome?.webview?.postMessage(message),
})
}
hosts.push(...resolveWebkitHandlers())
const legacyExternal = (window as Window & { external?: { notify?: (message: string) => void } }).external
if (legacyExternal?.notify) {
hosts.push({
postMessage: (message: string) => legacyExternal.notify?.(message),
})
}
if (window.parent && window.parent !== window) {
hosts.push({
postMessage: (message: string) => window.parent.postMessage(message, '*'),
})
}
return hosts
}
class BackendBridge {
private readonly requestHandlers = new Map<string, RequestHandler>()
private readonly eventHandlers = new Map<string, Set<EventHandler>>()
private readonly pendingRequests = new Map<string, PendingRequest>()
constructor() {
window.addEventListener('message', this.handleWindowMessage)
window.addEventListener(INCOMING_EVENT_NAME, this.handleCustomIncoming as EventListener)
}
sendRequest = <TResponse = unknown, TPayload = unknown>(
topic: string,
payload: TPayload,
options?: RequestOptions,
): Promise<TResponse> => {
const requestMessage: RequestMessage<TPayload> = {
kind: 'requestMessage',
id: createMessageId(),
timestamp: new Date().toISOString(),
topic,
payload,
}
return new Promise<TResponse>((resolve, reject) => {
const timeoutMs = options?.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS
const timeoutHandle = window.setTimeout(() => {
this.pendingRequests.delete(requestMessage.id)
reject(new Error(`Bridge request timed out for topic "${topic}".`))
}, timeoutMs)
this.pendingRequests.set(requestMessage.id, {
resolve: (responsePayload) => resolve(responsePayload as TResponse),
reject,
timeoutHandle,
})
this.postMessage(requestMessage)
})
}
sendResponse = <TPayload = unknown>(
requestId: string,
topic: string,
payload: TPayload,
ok = true,
error?: string,
) => {
const responseMessage: ResponseMessage<TPayload> = {
kind: 'responseMessage',
id: createMessageId(),
requestId,
topic,
payload,
ok,
error,
timestamp: new Date().toISOString(),
}
this.postMessage(responseMessage)
}
emitEvent = <TPayload = unknown>(topic: string, payload: TPayload) => {
const eventMessage: EventMessage<TPayload> = {
kind: 'eventMessage',
id: createMessageId(),
timestamp: new Date().toISOString(),
topic,
payload,
}
this.postMessage(eventMessage)
}
onRequest = <TPayload = unknown, TResponse = unknown>(
topic: string,
handler: RequestHandler<TPayload, TResponse>,
) => {
this.requestHandlers.set(topic, handler as RequestHandler)
return () => {
this.requestHandlers.delete(topic)
}
}
onEvent = <TPayload = unknown>(topic: string, handler: EventHandler<TPayload>) => {
const handlers = this.eventHandlers.get(topic) ?? new Set<EventHandler>()
handlers.add(handler as EventHandler)
this.eventHandlers.set(topic, handlers)
return () => {
handlers.delete(handler as EventHandler)
if (handlers.size === 0) {
this.eventHandlers.delete(topic)
}
}
}
receiveFromBackend = (message: string | BridgeMessage) => {
const parsedMessage = parseBridgeMessage(message)
if (!parsedMessage) {
return false
}
void this.dispatchIncoming(parsedMessage)
return true
}
private readonly handleWindowMessage = (event: MessageEvent) => {
this.receiveFromBackend(event.data)
}
private readonly handleCustomIncoming = (event: Event) => {
const customEvent = event as CustomEvent<string | BridgeMessage>
this.receiveFromBackend(customEvent.detail)
}
private postMessage(message: BridgeMessage) {
const serializedMessage = JSON.stringify(message)
for (const host of resolveOutboundHosts()) {
try {
host.postMessage(serializedMessage)
} catch {
// Best effort transport fan-out.
}
}
window.dispatchEvent(
new CustomEvent<string>(OUTGOING_EVENT_NAME, {
detail: serializedMessage,
}),
)
}
private async dispatchIncoming(message: BridgeMessage) {
if (message.kind === 'responseMessage') {
this.resolvePendingRequest(message)
return
}
if (message.kind === 'requestMessage') {
await this.handleRequestMessage(message)
return
}
await this.handleEventMessage(message)
}
private resolvePendingRequest(message: ResponseMessage) {
const pendingRequest = this.pendingRequests.get(message.requestId)
if (!pendingRequest) {
return
}
window.clearTimeout(pendingRequest.timeoutHandle)
this.pendingRequests.delete(message.requestId)
if (!message.ok) {
pendingRequest.reject(new Error(message.error ?? `Bridge request failed for topic "${message.topic}".`))
return
}
pendingRequest.resolve(message.payload)
}
private async handleRequestMessage(message: RequestMessage) {
const handler = this.requestHandlers.get(message.topic)
if (!handler) {
this.sendResponse(message.id, message.topic, null, false, `No request handler for topic "${message.topic}".`)
return
}
try {
const payload = await handler(message)
this.sendResponse(message.id, message.topic, payload, true)
} catch (error) {
this.sendResponse(message.id, message.topic, null, false, normalizeError(error))
}
}
private async handleEventMessage(message: EventMessage) {
const handlers = this.eventHandlers.get(message.topic)
if (!handlers || handlers.size === 0) {
return
}
for (const handler of handlers) {
await handler(message)
}
}
}
export const backendBridge = new BackendBridge()
export const uiBridgeApi: UiBridgeApi = {
receiveFromBackend: backendBridge.receiveFromBackend,
sendRequest: backendBridge.sendRequest,
emitEvent: backendBridge.emitEvent,
}
window.kwaUiBridge = uiBridgeApi
export const bridgeEventNames = {
incoming: INCOMING_EVENT_NAME,
outgoing: OUTGOING_EVENT_NAME,
}

View File

@@ -0,0 +1,80 @@
import { watch } from 'vue'
import { backendBridge } from './backendBridge'
import { useRequirementsStore, type Requirement } from '../stores/requirements'
interface RequirementSelectionPayload {
id: number
}
interface RequirementUpdatePayload {
requirement: Requirement
}
interface RequirementReplacePayload {
requirements: Requirement[]
}
export const installRequirementsBridge = (store: ReturnType<typeof useRequirementsStore>) => {
backendBridge.onRequest('requirements.list', () => {
return {
requirements: store.requirements,
selectedId: store.selectedId,
}
})
backendBridge.onRequest<RequirementSelectionPayload>('requirements.get', ({ payload }) => {
return {
requirement: store.getRequirementById(payload.id),
}
})
backendBridge.onRequest<RequirementSelectionPayload>('requirements.select', ({ payload }) => {
return {
requirement: store.selectRequirement(payload.id),
}
})
backendBridge.onRequest<RequirementUpdatePayload>('requirements.update', ({ payload }) => {
return {
requirement: store.updateRequirement(payload.requirement),
}
})
backendBridge.onEvent<RequirementReplacePayload>('requirements.replaceAll', ({ payload }) => {
store.setRequirements(payload.requirements)
})
backendBridge.onEvent<RequirementSelectionPayload>('requirements.select', ({ payload }) => {
store.selectRequirement(payload.id)
})
store.$onAction(({ name, after }) => {
after((result) => {
if (name === 'updateRequirement' && result) {
backendBridge.emitEvent('requirements.updated', {
requirement: result,
})
}
if (name === 'setRequirements') {
backendBridge.emitEvent('requirements.replaced', {
requirements: store.requirements,
selectedId: store.selectedId,
})
}
})
})
watch(
() => store.selectedId,
(selectedId) => {
backendBridge.emitEvent('requirements.selectionChanged', {
selectedId,
requirement: store.getRequirementById(selectedId),
})
},
{ immediate: false },
)
backendBridge.emitEvent('ui.ready', {})
}

View File

@@ -0,0 +1,270 @@
<script setup lang="ts">
import { computed } from 'vue'
import Menubar from 'primevue/menubar'
import type { MenuItem } from 'primevue/menuitem'
interface Props {
activeView: 'home' | 'requirements'
}
const props = defineProps<Props>()
const emit = defineEmits<{
home: []
requirements: []
}>()
const menuItems = computed<MenuItem[]>(() => [
{
label: 'File',
items: [
{
label: 'New',
icon: 'pi pi-fw pi-file',
command: () => handleNew(),
},
{
label: 'Open',
icon: 'pi pi-fw pi-folder-open',
command: () => handleOpen(),
},
{
label: 'Save',
icon: 'pi pi-fw pi-save',
command: () => handleSave(),
},
{
label: 'Save As...',
icon: 'pi pi-fw pi-save',
command: () => handleSaveAs(),
},
{ separator: true },
{
label: 'Close',
icon: 'pi pi-fw pi-times',
command: () => handleClose(),
},
{
label: 'Quit',
icon: 'pi pi-fw pi-power-off',
command: () => handleQuit(),
},
],
},
{
label: 'Edit',
items: [
{
label: 'Undo',
icon: 'pi pi-fw pi-undo',
command: () => handleUndo(),
},
{
label: 'Redo',
icon: 'pi pi-fw pi-redo',
command: () => handleRedo(),
},
{ separator: true },
{
label: 'Cut',
icon: 'pi pi-fw pi-cut',
command: () => handleCut(),
},
{
label: 'Copy',
icon: 'pi pi-fw pi-copy',
command: () => handleCopy(),
},
{
label: 'Paste',
icon: 'pi pi-fw pi-paste',
command: () => handlePaste(),
},
],
},
{
label: 'View',
items: [
{
label: 'Refresh',
icon: 'pi pi-fw pi-refresh',
command: () => handleRefresh(),
},
{
label: 'Zoom In',
icon: 'pi pi-fw pi-search-plus',
command: () => handleZoomIn(),
},
{
label: 'Zoom Out',
icon: 'pi pi-fw pi-search-minus',
command: () => handleZoomOut(),
},
],
},
{
label: 'Windows',
items: [
{
label: 'Home',
icon: props.activeView === 'home' ? 'pi pi-fw pi-check' : 'pi pi-fw pi-home',
command: () => emit('home'),
},
{
label: 'Requirements',
icon:
props.activeView === 'requirements'
? 'pi pi-fw pi-check'
: 'pi pi-fw pi-list-check',
command: () => emit('requirements'),
},
],
},
{
label: 'Tools',
items: [
{
label: 'Settings',
icon: 'pi pi-fw pi-cog',
command: () => handleSettings(),
},
],
},
{
label: 'Help',
items: [
{
label: 'About',
icon: 'pi pi-fw pi-info-circle',
command: () => handleAbout(),
},
{
label: 'Documentation',
icon: 'pi pi-fw pi-book',
command: () => handleDocumentation(),
},
],
},
])
// Handler functions
const handleNew = () => {
console.log('New file')
}
const handleOpen = () => {
console.log('Open file')
}
const handleSave = () => {
console.log('Save')
}
const handleSaveAs = () => {
console.log('Save As')
}
const handleClose = () => {
console.log('Close')
}
const handleQuit = () => {
if (confirm('Are you sure you want to quit?')) {
console.log('Quit application')
}
}
const handleUndo = () => {
console.log('Undo')
}
const handleRedo = () => {
console.log('Redo')
}
const handleCut = () => {
console.log('Cut')
}
const handleCopy = () => {
console.log('Copy')
}
const handlePaste = () => {
console.log('Paste')
}
const handleRefresh = () => {
console.log('Refresh')
window.location.reload()
}
const handleZoomIn = () => {
console.log('Zoom In')
}
const handleZoomOut = () => {
console.log('Zoom Out')
}
const handleSettings = () => {
console.log('Settings')
}
const handleAbout = () => {
console.log('About')
}
const handleDocumentation = () => {
console.log('Documentation')
}
</script>
<template>
<nav class="app-menubar">
<Menubar :model="menuItems" />
</nav>
</template>
<style scoped>
.app-menubar {
border-bottom: 1px solid rgba(96, 117, 156, 0.16);
position: sticky;
top: 0;
z-index: 1000;
}
:deep(.p-menubar) {
background: rgba(255, 255, 255, 0.95);
border: none;
border-radius: 0;
padding: 0;
backdrop-filter: blur(12px);
z-index: 1000;
}
:deep(.p-menubar-root-list) {
gap: 0;
}
:deep(.p-menubar-item-label) {
font-weight: 500;
color: #44556f;
}
:deep(.p-menubar-item:hover > .p-menubar-item-label) {
color: #12213a;
}
:deep(.p-menubar-item.p-menubar-item-active > .p-menubar-item-label) {
color: #3b82f6;
}
:deep(.p-submenu-list) {
background: rgba(255, 255, 255, 0.98);
border-radius: 0.5rem;
border: 1px solid rgba(96, 117, 156, 0.14);
box-shadow: 0 12px 40px rgba(34, 49, 77, 0.12);
z-index: 1001;
}
</style>

View File

@@ -0,0 +1,85 @@
<script setup lang="ts">
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useRequirementsStore } from '../stores/requirements'
const requirementsStore = useRequirementsStore()
const { requirements, selectedRequirement, stats } = storeToRefs(requirementsStore)
const statusMessage = computed(() => {
if (!selectedRequirement.value) {
return 'No requirement selected'
}
return `${selectedRequirement.value.reference} - ${selectedRequirement.value.title}`
})
const detailsMessage = computed(() => {
return `Total: ${stats.value.total} | Approved: ${stats.value.approvedCount} | Blocked: ${stats.value.blockedCount}`
})
</script>
<template>
<footer class="app-statusbar">
<div class="status-section">
<i :class="`pi ${selectedRequirement ? 'pi-check-circle text-success' : 'pi-info-circle text-info'}`" />
<span class="status-text">{{ statusMessage }}</span>
</div>
<div class="status-divider" />
<div class="status-section">
<span class="status-details">{{ detailsMessage }}</span>
</div>
</footer>
</template>
<style scoped>
.app-statusbar {
display: flex;
align-items: center;
height: 2rem;
padding: 0 1rem;
background: rgba(255, 255, 255, 0.95);
border-top: 1px solid rgba(96, 117, 156, 0.16);
font-size: 0.8rem;
color: #6c7b97;
gap: 1rem;
backdrop-filter: blur(12px);
}
.status-section {
display: flex;
align-items: center;
gap: 0.5rem;
overflow: hidden;
text-overflow: ellipsis;
}
.status-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-details {
white-space: nowrap;
}
.status-divider {
width: 1px;
height: 1rem;
background: rgba(96, 117, 156, 0.14);
}
:deep(.pi) {
font-size: 0.9rem;
}
:deep(.pi.text-success) {
color: #22c55e;
}
:deep(.pi.text-info) {
color: #3b82f6;
}
</style>

View File

@@ -0,0 +1,152 @@
<script setup lang="ts">
import Toolbar from 'primevue/toolbar'
import Button from 'primevue/button'
import Divider from 'primevue/divider'
interface Props {
activeView: 'home' | 'requirements'
}
defineProps<Props>()
const emit = defineEmits<{
home: []
requirements: []
new: []
open: []
save: []
}>()
const handleHome = () => emit('home')
const handleRequirements = () => emit('requirements')
const handleNew = () => emit('new')
const handleOpen = () => emit('open')
const handleSave = () => emit('save')
</script>
<template>
<Toolbar class="app-toolbar">
<template #start>
<div class="toolbar-section">
<Button
icon="pi pi-file"
rounded
text
severity="secondary"
@click="handleNew"
v-tooltip="'New'"
/>
<Button
icon="pi pi-folder-open"
rounded
text
severity="secondary"
@click="handleOpen"
v-tooltip="'Open'"
/>
<Button
icon="pi pi-save"
rounded
text
severity="secondary"
@click="handleSave"
v-tooltip="'Save'"
/>
<Divider layout="vertical" />
<Button
class="nav-button"
icon="pi pi-home"
rounded
text
:severity="activeView === 'home' ? 'contrast' : 'secondary'"
@click="handleHome"
v-tooltip="'Home'"
/>
<Button
class="nav-button"
icon="pi pi-list-check"
rounded
text
:severity="activeView === 'requirements' ? 'contrast' : 'secondary'"
@click="handleRequirements"
v-tooltip="'Requirements'"
/>
</div>
</template>
<template #end>
<div class="toolbar-section">
<Button
icon="pi pi-bell"
rounded
text
severity="secondary"
@click="() => console.log('Notifications')"
v-tooltip="'Notifications'"
/>
<Button
icon="pi pi-user"
rounded
text
severity="secondary"
@click="() => console.log('Profile')"
v-tooltip="'Profile'"
/>
</div>
</template>
</Toolbar>
</template>
<style scoped>
.app-toolbar {
border-bottom: 1px solid rgba(96, 117, 156, 0.16);
background: rgba(255, 255, 255, 0.95);
padding: 0.5rem 1rem;
backdrop-filter: blur(12px);
z-index: 999;
}
:deep(.p-toolbar) {
background: transparent;
border: none;
padding: 0;
gap: 0;
}
:deep(.p-toolbar-group-start),
:deep(.p-toolbar-group-end) {
gap: 0;
}
.toolbar-section {
display: flex;
align-items: center;
gap: 0.25rem;
}
:deep(.nav-button.p-button) {
transition: background-color 0.2s ease, color 0.2s ease;
}
:deep(.nav-button.p-button-contrast) {
background: rgba(78, 107, 255, 0.15);
color: #1e3a8a;
}
:deep(.p-button.p-button-rounded) {
width: 2.5rem;
height: 2.5rem;
}
:deep(.p-divider-vertical) {
height: 1.5rem;
margin: 0 0.5rem;
background: rgba(96, 117, 156, 0.14);
}
:deep(.p-tooltip) {
font-size: 0.75rem;
}
</style>

View File

@@ -1,41 +0,0 @@
<script setup lang="ts">
defineProps<{
msg: string
}>()
</script>
<template>
<div class="greetings">
<h1 class="green">{{ msg }}</h1>
<h3>
Youve successfully created a project with
<a href="https://vite.dev/" target="_blank" rel="noopener">Vite</a> +
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>. What's next?
</h3>
</div>
</template>
<style scoped>
h1 {
font-weight: 500;
font-size: 2.6rem;
position: relative;
top: -10px;
}
h3 {
font-size: 1.2rem;
}
.greetings h1,
.greetings h3 {
text-align: center;
}
@media (min-width: 1024px) {
.greetings h1,
.greetings h3 {
text-align: left;
}
}
</style>

View File

@@ -0,0 +1,711 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import Button from 'primevue/button'
import Chip from 'primevue/chip'
import Divider from 'primevue/divider'
import InputText from 'primevue/inputtext'
import TabView from 'primevue/tabview'
import TabPanel from 'primevue/tabpanel'
import Tag from 'primevue/tag'
import Textarea from 'primevue/textarea'
import type { Requirement, RequirementPriority, RequirementStatus } from '../stores/requirements'
interface Props {
requirement: Requirement
}
interface FormState {
title: string
owner: string
status: RequirementStatus
priority: RequirementPriority
description: string
flexibility: string
tolerances: string
notes: string
stakeholders: string
acceptanceCriteria: string
impactedModules: string
blockers: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'save', requirement: Requirement): void
(e: 'dirty-change', value: boolean): void
}>()
const isEditing = ref(false)
const form = reactive<FormState>({
title: '',
owner: '',
status: 'Draft',
priority: 'Low',
description: '',
flexibility: '',
tolerances: '',
notes: '',
stakeholders: '',
acceptanceCriteria: '',
impactedModules: '',
blockers: '',
})
const errors = reactive<Record<string, string>>({})
const normalizeCsv = (value: string) => csvToList(value).join(',')
const normalizeMultiline = (value: string) => multilineToList(value).join('|')
const listToCsv = (value: string[]) => value.join(', ')
const listToMultiline = (value: string[]) => value.join('\n')
const csvToList = (value: string) =>
value
.split(',')
.map((item) => item.trim())
.filter(Boolean)
const multilineToList = (value: string) =>
value
.split('\n')
.map((item) => item.trim())
.filter(Boolean)
const resetForm = (requirement: Requirement) => {
form.title = requirement.title
form.owner = requirement.owner
form.status = requirement.status
form.priority = requirement.priority
form.description = requirement.description
form.flexibility = requirement.flexibility
form.tolerances = requirement.tolerances
form.notes = requirement.notes
form.stakeholders = listToCsv(requirement.stakeholders)
form.acceptanceCriteria = listToMultiline(requirement.acceptanceCriteria)
form.impactedModules = listToCsv(requirement.impactedModules)
form.blockers = listToMultiline(requirement.blockers)
}
const clearErrors = () => {
Object.keys(errors).forEach((key) => delete errors[key])
}
const startEdit = () => {
resetForm(props.requirement)
clearErrors()
isEditing.value = true
}
const cancelEdit = () => {
isEditing.value = false
clearErrors()
resetForm(props.requirement)
}
const validate = () => {
clearErrors()
if (!form.title.trim()) {
errors.title = 'Title is required.'
}
if (!form.owner.trim()) {
errors.owner = 'Author is required.'
}
if (!form.description.trim()) {
errors.description = 'Description is required.'
}
if (csvToList(form.stakeholders).length === 0) {
errors.stakeholders = 'At least one stakeholder is required.'
}
return Object.keys(errors).length === 0
}
const saveEdit = () => {
if (!validate()) {
return
}
isEditing.value = false
emit('save', {
...props.requirement,
title: form.title.trim(),
owner: form.owner.trim(),
status: form.status,
priority: form.priority,
description: form.description.trim(),
flexibility: form.flexibility.trim(),
tolerances: form.tolerances.trim(),
notes: form.notes.trim(),
stakeholders: csvToList(form.stakeholders),
acceptanceCriteria: multilineToList(form.acceptanceCriteria),
impactedModules: csvToList(form.impactedModules),
blockers: multilineToList(form.blockers),
})
clearErrors()
}
const isDirty = computed(() => {
if (!isEditing.value) {
return false
}
return (
form.title.trim() !== props.requirement.title ||
form.owner.trim() !== props.requirement.owner ||
form.status !== props.requirement.status ||
form.priority !== props.requirement.priority ||
form.description.trim() !== props.requirement.description ||
form.flexibility.trim() !== props.requirement.flexibility ||
form.tolerances.trim() !== props.requirement.tolerances ||
form.notes.trim() !== props.requirement.notes ||
normalizeCsv(form.stakeholders) !== props.requirement.stakeholders.join(',') ||
normalizeCsv(form.impactedModules) !== props.requirement.impactedModules.join(',') ||
normalizeMultiline(form.acceptanceCriteria) !==
props.requirement.acceptanceCriteria.join('|') ||
normalizeMultiline(form.blockers) !== props.requirement.blockers.join('|')
)
})
const statusSeverity = (status: RequirementStatus) => {
const map: Record<RequirementStatus, 'secondary' | 'info' | 'success' | 'danger' | 'contrast'> = {
Draft: 'secondary',
'In Review': 'info',
Approved: 'success',
Blocked: 'danger',
Delivered: 'contrast',
}
return map[status]
}
const prioritySeverity = (priority: RequirementPriority) => {
const map: Record<RequirementPriority, 'success' | 'info' | 'warn' | 'danger'> = {
Low: 'success',
Medium: 'info',
High: 'warn',
Critical: 'danger',
}
return map[priority]
}
watch(
() => props.requirement,
(requirement) => {
resetForm(requirement)
clearErrors()
isEditing.value = false
},
{ immediate: true, deep: true }
)
watch(isDirty, (value) => {
emit('dirty-change', value)
}, { immediate: true })
</script>
<template>
<section class="detail-zone">
<section class="detail-surface">
<header
class="detail-card__header"
:class="{ 'detail-card__header--editing': isEditing }"
>
<div class="detail-card__main">
<p class="eyebrow">Selected requirement</p>
<h2 v-if="!isEditing">{{ requirement.title }}</h2>
<InputText
v-else
v-model="form.title"
class="edit-input"
placeholder="Requirement title"
/>
<small v-if="errors.title" class="error-text">{{ errors.title }}</small>
<p class="detail-card__subtitle">
{{ requirement.reference }} - Owner: {{ isEditing ? form.owner : requirement.owner }}
</p>
<p class="detail-card__uuid">UUID: {{ requirement.uuid }}</p>
</div>
<div class="detail-actions">
<Button
v-if="!isEditing"
label="Edit"
icon="pi pi-pencil"
severity="secondary"
outlined
@click="startEdit"
/>
</div>
</header>
<TabView class="detail-tabs">
<TabPanel value="details" header="Details">
<div class="detail-badges">
<Tag
v-if="!isEditing"
:value="requirement.status"
:severity="statusSeverity(requirement.status)"
/>
<select v-else v-model="form.status" class="edit-select">
<option value="Draft">Draft</option>
<option value="In Review">In Review</option>
<option value="Approved">Approved</option>
<option value="Blocked">Blocked</option>
<option value="Delivered">Delivered</option>
</select>
<Tag
v-if="!isEditing"
:value="requirement.priority"
:severity="prioritySeverity(requirement.priority)"
rounded
/>
<select v-else v-model="form.priority" class="edit-select">
<option value="Low">Low</option>
<option value="Medium">Medium</option>
<option value="High">High</option>
<option value="Critical">Critical</option>
</select>
</div>
<p v-if="!isEditing" class="detail-description">{{ requirement.description }}</p>
<div v-else>
<Textarea
v-model="form.description"
class="edit-textarea"
rows="3"
placeholder="Requirement description"
/>
<small v-if="errors.description" class="error-text">{{ errors.description }}</small>
</div>
<article v-if="isEditing || requirement.blockers.length" class="info-panel info-panel--warning">
<h3>Identified blockers</h3>
<ul v-if="!isEditing" class="blockers-list">
<li v-for="blocker in requirement.blockers" :key="blocker">
<i class="pi pi-exclamation-triangle" />
<span>{{ blocker }}</span>
</li>
</ul>
<Textarea
v-else
v-model="form.blockers"
class="edit-textarea"
rows="3"
placeholder="One blocker per line"
/>
</article>
<Divider />
<div class="detail-grid">
<article class="info-panel">
<h3>Author</h3>
<p v-if="!isEditing">{{ requirement.owner }}</p>
<div v-else>
<InputText v-model="form.owner" class="edit-input" placeholder="Author" />
<small v-if="errors.owner" class="error-text">{{ errors.owner }}</small>
</div>
</article>
<article class="info-panel">
<h3>Stakeholders</h3>
<div v-if="!isEditing" class="chip-row">
<Chip v-for="stakeholder in requirement.stakeholders" :key="stakeholder" :label="stakeholder" />
</div>
<div v-else>
<InputText
v-model="form.stakeholders"
class="edit-input"
placeholder="Comma separated stakeholders"
/>
<small v-if="errors.stakeholders" class="error-text">{{ errors.stakeholders }}</small>
</div>
</article>
<article class="info-panel">
<h3>Flexibility</h3>
<p v-if="!isEditing">{{ requirement.flexibility }}</p>
<Textarea
v-else
v-model="form.flexibility"
class="edit-textarea"
rows="2"
placeholder="Flexibility constraints"
/>
</article>
<article class="info-panel">
<h3>Tolerances</h3>
<p v-if="!isEditing">{{ requirement.tolerances }}</p>
<Textarea
v-else
v-model="form.tolerances"
class="edit-textarea"
rows="2"
placeholder="Tolerance details"
/>
</article>
</div>
<Divider />
<div class="detail-columns">
<article class="info-panel">
<h3>Acceptance criteria</h3>
<ul v-if="!isEditing" class="checklist">
<li v-for="item in requirement.acceptanceCriteria" :key="item">
<i class="pi pi-check-circle" />
<span>{{ item }}</span>
</li>
</ul>
<Textarea
v-else
v-model="form.acceptanceCriteria"
class="edit-textarea"
rows="6"
placeholder="One acceptance criterion per line"
/>
</article>
<article class="info-panel">
<h3>Impacted modules</h3>
<div v-if="!isEditing" class="chip-row">
<Chip v-for="module in requirement.impactedModules" :key="module" :label="module" />
</div>
<InputText
v-else
v-model="form.impactedModules"
class="edit-input"
placeholder="Comma separated modules"
/>
<Divider />
<h3>Remarks</h3>
<p v-if="!isEditing">{{ requirement.notes }}</p>
<Textarea
v-else
v-model="form.notes"
class="edit-textarea"
rows="4"
placeholder="Remarks"
/>
</article>
</div>
</TabPanel>
<TabPanel value="tests" header="Tests to perform">
<div class="tests-placeholder">
<i class="pi pi-inbox" />
<p>No tests defined yet</p>
</div>
</TabPanel>
</TabView>
<footer v-if="isEditing" class="edit-footer">
<Button
label="Cancel"
icon="pi pi-times"
severity="secondary"
outlined
@click="cancelEdit"
/>
<Button
label="Save"
icon="pi pi-check"
@click="saveEdit"
/>
</footer>
</section>
</section>
</template>
<style scoped>
.detail-zone {
min-width: 0;
min-height: 0;
height: 100%;
}
.detail-surface {
display: flex;
flex-direction: column;
height: 100%;
padding: 1rem;
border: none;
border-radius: 0;
background: rgba(255, 255, 255, 0.86);
box-shadow: 0 24px 60px rgba(34, 49, 77, 0.12);
backdrop-filter: blur(18px);
}
.detail-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.detail-card__header--editing {
display: block;
}
.detail-card__header--editing .detail-card__main {
width: 100%;
}
.detail-card__header--editing .detail-actions {
margin-top: 0.5rem;
justify-content: flex-start;
}
.detail-card__main {
flex: 1;
min-width: 0;
}
.eyebrow {
margin: 0;
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 0.72rem;
font-weight: 700;
color: #5c6b88;
}
.detail-surface h2 {
margin: 0;
color: #12213a;
}
.detail-card__subtitle {
margin: 0.5rem 0 0;
color: #6c7b97;
font-size: 0.82rem;
}
.detail-card__uuid {
margin: 0.35rem 0 0;
color: #7c8aa5;
font-size: 0.75rem;
font-family: 'Consolas', 'Courier New', monospace;
}
.detail-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.5rem;
}
.edit-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding-top: 0.75rem;
border-top: 1px solid rgba(96, 117, 156, 0.12);
}
.detail-tabs {
min-height: 0;
flex: 1;
}
:deep(.detail-tabs.p-tabview) {
display: flex;
flex-direction: column;
min-height: 0;
}
:deep(.detail-tabs .p-tabview-nav-container),
:deep(.detail-tabs .p-tabview-nav-content),
:deep(.detail-tabs .p-tabview-nav) {
width: 100%;
min-width: 0;
box-sizing: border-box;
}
:deep(.detail-tabs .p-tabview-nav) {
position: relative;
border-bottom: 1px solid rgba(96, 117, 156, 0.2) !important;
}
:deep(.detail-tabs .p-tabview-nav::before),
:deep(.detail-tabs .p-tabview-nav::after) {
content: none !important;
}
:deep(.detail-tabs .p-tabview-ink-bar) {
display: none !important;
}
:deep(.detail-tabs [role='tab']) {
border-bottom: 2px solid transparent !important;
margin-bottom: -1px;
}
:deep(.detail-tabs [role='tab'][aria-selected='true']) {
border-bottom-color: #2563eb !important;
color: #1d4ed8 !important;
}
:deep(.detail-tabs .p-tabview-panels) {
flex: 1;
min-height: 0;
overflow: auto;
padding-left: 0;
padding-right: 0;
}
.detail-badges {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
margin-bottom: 0.75rem;
}
.detail-description {
margin: 0;
color: #44556f;
font-size: 1.02rem;
}
.detail-grid,
.detail-columns {
display: grid;
gap: 0.75rem;
}
.detail-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
margin-top: 0.75rem;
}
.detail-columns {
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-top: 0.75rem;
}
.info-panel {
padding: 0.75rem;
border: 1px solid rgba(96, 117, 156, 0.14);
border-radius: 0.5rem;
background: rgba(247, 249, 255, 0.9);
}
.info-panel h3 {
margin: 0;
color: #12213a;
}
.info-panel p {
margin: 0.5rem 0 0;
color: #4c5d77;
}
.info-panel--warning {
margin-top: 0.75rem;
background: linear-gradient(180deg, rgba(255, 244, 236, 0.95), rgba(255, 249, 244, 0.95));
border-color: rgba(223, 134, 57, 0.24);
}
.checklist,
.blockers-list {
display: grid;
gap: 0.75rem;
margin: 0.75rem 0 0;
padding: 0;
list-style: none;
}
.checklist li,
.blockers-list li {
display: flex;
align-items: flex-start;
gap: 0.6rem;
color: #44556f;
}
.checklist i {
margin-top: 0.2rem;
color: #3b82f6;
}
.blockers-list i {
margin-top: 0.2rem;
color: #d97706;
}
.chip-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.75rem;
}
.tests-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 2rem 1rem;
color: #9ca3af;
}
.tests-placeholder i {
font-size: 2.5rem;
}
.edit-input,
.edit-textarea,
.edit-select {
width: 100%;
}
.edit-select {
border: 1px solid #cbd5e1;
border-radius: 0.5rem;
padding: 0.45rem 0.6rem;
background: #fff;
color: #334155;
}
.error-text {
display: block;
margin-top: 0.35rem;
color: #b91c1c;
font-size: 0.75rem;
}
@media (max-width: 1120px) {
.detail-grid,
.detail-columns {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.detail-card__header {
flex-direction: column;
}
.detail-actions {
width: 100%;
justify-content: flex-start;
}
.detail-grid,
.detail-columns {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,318 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import InputText from 'primevue/inputtext'
import Tree from 'primevue/tree'
import Tag from 'primevue/tag'
import type { Requirement, RequirementPriority, RequirementStatus } from '../stores/requirements'
export interface TreeNode {
key: string
label?: string
data?: Requirement
children?: TreeNode[]
icon?: string
}
interface Props {
treeData: TreeNode[]
searchQuery: string
selectedId: number
stats: {
total: number
approvedCount: number
blockedCount: number
criticalCount: number
}
}
interface Emits {
(e: 'update:searchQuery', value: string): void
(e: 'update:selectedId', value: number): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const expandedKeys = ref<Record<string, boolean>>({
'SECT-ELE': true,
'SECT-INFO': true,
'SECT-MEC': true,
})
const selectedKey = ref<string>('101')
const filteredTreeData = computed(() => {
const query = props.searchQuery.trim().toLowerCase()
if (!query) {
return props.treeData
}
const filterNode = (node: TreeNode): TreeNode | null => {
const matchesFilter =
node.label?.toLowerCase().includes(query) ||
(node.data?.reference.toLowerCase().includes(query) ?? false) ||
(node.data?.owner.toLowerCase().includes(query) ?? false)
const childrenMatch = node.children
?.map((child) => filterNode(child))
.filter((child) => child !== null) as TreeNode[]
if (matchesFilter || (childrenMatch && childrenMatch.length > 0)) {
return {
...node,
children: childrenMatch && childrenMatch.length > 0 ? childrenMatch : node.children,
}
}
return null
}
return props.treeData
.map((node) => filterNode(node))
.filter((node) => node !== null) as TreeNode[]
})
const statusSeverity = (status: Requirement['status']) => {
const map: Record<
RequirementStatus,
'secondary' | 'info' | 'success' | 'danger' | 'contrast'
> = {
Draft: 'secondary',
'In Review': 'info',
Approved: 'success',
Blocked: 'danger',
Delivered: 'contrast',
}
return map[status]
}
const prioritySeverity = (priority: Requirement['priority']) => {
const map: Record<RequirementPriority, 'success' | 'info' | 'warn' | 'danger'> = {
Low: 'success',
Medium: 'info',
High: 'warn',
Critical: 'danger',
}
return map[priority]
}
const onNodeSelect = (node: TreeNode) => {
if (node.data?.id) {
emit('update:selectedId', node.data.id)
selectedKey.value = node.key
}
}
</script>
<template>
<aside class="requirements-tree">
<div class="stats-bar">
<div class="stat-item">
<span class="stat-label">Total</span>
<strong>{{ stats.total }}</strong>
</div>
<div class="stat-item">
<span class="stat-label">Approved</span>
<strong>{{ stats.approvedCount }}</strong>
</div>
<div class="stat-item">
<span class="stat-label">Blocked</span>
<strong>{{ stats.blockedCount }}</strong>
</div>
<div class="stat-item">
<span class="stat-label">Critical</span>
<strong>{{ stats.criticalCount }}</strong>
</div>
</div>
<span class="search-field">
<InputText
:value="searchQuery"
@input="(event) => $emit('update:searchQuery', (event.target as HTMLInputElement).value)"
placeholder="Filter by title, ref, owner..."
/>
</span>
<div class="tree-container">
<Tree
:value="filteredTreeData"
:expanded-keys="expandedKeys"
@node-select="(node) => onNodeSelect(node)"
selection-mode="single"
:selection-keys="{ [selectedKey]: true }"
@update:expanded-keys="(keys) => (expandedKeys = keys)"
class="requirements-tree-view"
>
<template #default="{ node }">
<div class="tree-node-content">
<span class="node-label">{{ node.label }}</span>
<div v-if="node.data" class="node-tags">
<Tag :value="node.data.status" :severity="statusSeverity(node.data.status)" />
<Tag
:value="node.data.priority"
:severity="prioritySeverity(node.data.priority)"
rounded
/>
</div>
</div>
</template>
</Tree>
</div>
</aside>
</template>
<style scoped>
.requirements-tree {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
border: none;
border-radius: 0;
background: rgba(255, 255, 255, 0.86);
box-shadow: 0 24px 60px rgba(34, 49, 77, 0.12);
backdrop-filter: blur(18px);
height: 100%;
overflow: hidden;
}
.stats-bar {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.35rem;
padding-bottom: 0.35rem;
border-bottom: 1px solid rgba(96, 117, 156, 0.12);
}
.stat-item {
display: flex;
flex-direction: column;
gap: 0.2rem;
text-align: center;
}
.stat-label {
display: block;
color: #667690;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.stat-item strong {
color: #12213a;
font-size: 0.95rem;
}
.search-field {
width: 100%;
flex-shrink: 0;
}
.search-field :is(.p-inputtext) {
width: 100%;
border-radius: 0.5rem;
padding-left: 0.85rem;
font-size: 0.9rem;
}
.tree-container {
flex: 1;
min-height: 0;
overflow-y: auto;
border-radius: 0.5rem;
}
.requirements-tree-view {
width: 100%;
border: none;
background: transparent;
}
.requirements-tree-view :deep(.p-tree) {
background: transparent;
border: none;
}
.requirements-tree-view :deep(.p-tree-container) {
background: transparent;
}
.requirements-tree-view :deep(.p-treenode) {
padding: 0;
}
.requirements-tree-view :deep(.p-treenode-content) {
padding: 0.5rem 0.5rem;
border-radius: 0.6rem;
transition: background-color 0.2s ease;
}
.requirements-tree-view :deep(.p-treenode-content:hover) {
background-color: rgba(78, 107, 255, 0.08);
}
.requirements-tree-view :deep(.p-treenode-content.p-treenode-selected) {
background-color: rgba(78, 107, 255, 0.16);
color: #12213a;
}
.tree-node-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
width: 100%;
}
.node-label {
flex: 1;
font-size: 0.85rem;
color: #12213a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.node-tags {
display: flex;
gap: 0.25rem;
flex-shrink: 0;
}
.node-tags :deep(.p-tag) {
font-size: 0.7rem;
padding: 0.25rem 0.5rem;
}
@media (max-width: 760px) {
.stats-bar {
grid-template-columns: repeat(2, 1fr);
}
.requirements-tree {
padding: 0.6rem;
gap: 0.4rem;
}
.search-field :is(.p-inputtext) {
font-size: 0.85rem;
padding-left: 0.75rem;
}
.node-label {
font-size: 0.8rem;
}
.stat-label {
font-size: 0.65rem;
}
.stat-item strong {
font-size: 0.85rem;
}
}
</style>

View File

@@ -1,95 +0,0 @@
<script setup lang="ts">
import WelcomeItem from './WelcomeItem.vue'
import DocumentationIcon from './icons/IconDocumentation.vue'
import ToolingIcon from './icons/IconTooling.vue'
import EcosystemIcon from './icons/IconEcosystem.vue'
import CommunityIcon from './icons/IconCommunity.vue'
import SupportIcon from './icons/IconSupport.vue'
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
</script>
<template>
<WelcomeItem>
<template #icon>
<DocumentationIcon />
</template>
<template #heading>Documentation</template>
Vues
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
provides you with all information you need to get started.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<ToolingIcon />
</template>
<template #heading>Tooling</template>
This project is served and bundled with
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
recommended IDE setup is
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
+
<a href="https://github.com/vuejs/language-tools" target="_blank" rel="noopener"
>Vue - Official</a
>. If you need to test your components and web pages, check out
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
and
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
/
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
<br />
More instructions are available in
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
>.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<EcosystemIcon />
</template>
<template #heading>Ecosystem</template>
Get official tools and libraries for your project:
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
you need more resources, we suggest paying
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
a visit.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<CommunityIcon />
</template>
<template #heading>Community</template>
Got stuck? Ask your question on
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
(our official Discord server), or
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
>StackOverflow</a
>. You should also follow the official
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
Bluesky account or the
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
X account for latest news in the Vue world.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<SupportIcon />
</template>
<template #heading>Support Vue</template>
As an independent project, Vue relies on community backing for its sustainability. You can help
us by
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
</WelcomeItem>
</template>

View File

@@ -1,87 +0,0 @@
<template>
<div class="item">
<i>
<slot name="icon"></slot>
</i>
<div class="details">
<h3>
<slot name="heading"></slot>
</h3>
<slot></slot>
</div>
</div>
</template>
<style scoped>
.item {
margin-top: 2rem;
display: flex;
position: relative;
}
.details {
flex: 1;
margin-left: 1rem;
}
i {
display: flex;
place-items: center;
place-content: center;
width: 32px;
height: 32px;
color: var(--color-text);
}
h3 {
font-size: 1.2rem;
font-weight: 500;
margin-bottom: 0.4rem;
color: var(--color-heading);
}
@media (min-width: 1024px) {
.item {
margin-top: 0;
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
}
i {
top: calc(50% - 25px);
left: -26px;
position: absolute;
border: 1px solid var(--color-border);
background: var(--color-background);
border-radius: 8px;
width: 50px;
height: 50px;
}
.item:before {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
bottom: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:after {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
top: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:first-of-type:before {
display: none;
}
.item:last-of-type:after {
display: none;
}
}
</style>

View File

@@ -1,7 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
/>
</svg>
</template>

View File

@@ -1,7 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
<path
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
/>
</svg>
</template>

View File

@@ -1,7 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
<path
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
/>
</svg>
</template>

View File

@@ -1,7 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
/>
</svg>
</template>

View File

@@ -1,19 +0,0 @@
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
class="iconify iconify--mdi"
width="24"
height="24"
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 24 24"
>
<path
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
fill="currentColor"
></path>
</svg>
</template>

View File

@@ -3,25 +3,41 @@ import './assets/main.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config';
import Aura from '@primeuix/themes/aura';
import 'primeicons/primeicons.css';
import PrimeVue from 'primevue/config'
import Aura from '@primeuix/themes/aura'
import 'primeicons/primeicons.css'
import App from './App.vue'
import router from './router'
import { installRequirementsBridge } from './bridge/requirementsBridge'
import { useRequirementsStore } from './stores/requirements'
const loadEmbeddedFonts = async () => {
await Promise.allSettled([
import('@fontsource/source-sans-3/latin-400.css'),
import('@fontsource/source-sans-3/latin-500.css'),
import('@fontsource/source-sans-3/latin-600.css'),
import('@fontsource/source-sans-3/latin-700.css'),
])
}
const app = createApp(App)
const pinia = createPinia()
app.use(createPinia())
app.use(pinia)
app.use(router)
app.use(PrimeVue, {
theme: {
preset: Aura, // Use the Aura theme. It's a modern and elegant theme that provides a great user experience.
options: {
darkModeSelector: 'system', // Adapts to the Windows dark mode
}
}
});
theme: {
preset: Aura,
options: {
darkModeSelector: false,
},
},
})
installRequirementsBridge(useRequirementsStore(pinia))
app.mount('#app')
// Do not block bridge initialization on font asset loading in embedded hosts.
void loadEmbeddedFonts()

View File

@@ -1,15 +1,9 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import { routes } from './routes'
const router = createRouter({
history: createWebHashHistory(),
routes: [
{
path: '/',
name: 'home',
component: HomeView,
},
],
routes,
})
export default router

View File

@@ -0,0 +1,20 @@
import type { RouteRecordRaw } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import RequirementsView from '../views/RequirementsView.vue'
export const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'home',
component: HomeView,
},
{
path: '/requirements',
name: 'requirements',
component: RequirementsView,
},
{
path: '/:pathMatch(.*)*',
redirect: { name: 'home' },
},
]

View File

@@ -1,12 +0,0 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})

View File

@@ -0,0 +1,224 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
export type RequirementStatus = 'Draft' | 'In Review' | 'Approved' | 'Blocked' | 'Delivered'
export type RequirementPriority = 'Low' | 'Medium' | 'High' | 'Critical'
export interface Requirement {
id: number // Internal identifier, not exposed to users
reference: string // User-facing reference code (e.g., "REQ-101")
uuid: string // Unique identifier for traceability
title: string // Short, descriptive title of the requirement
description: string // Detailed description of the requirement
status: RequirementStatus // Current lifecycle status of the requirement
priority: RequirementPriority // Business priority level for implementation
owner: string // Person responsible for the requirement
progress: number // Completion percentage of the requirement
dueDate: string // Expected completion date
rationale: string // Reasoning behind the requirement
acceptanceCriteria: string[] // List of conditions that must be met for acceptance
impactedModules: string[] // List of application modules affected by this requirement
blockers: string[] // List of issues preventing progress on this requirement
notes: string // Additional information or comments about the requirement
stakeholders: string[] // List of individuals or groups with an interest in the requirement
flexibility: string // Degree of flexibility in requirement implementation (e.g., "Low", "Medium", "High")
tolerances: string // Acceptable limits for deviations from the requirement (e.g., "Maximum 5% of requirements can have incomplete artifact links")
}
const cloneRequirement = (requirement: Requirement): Requirement => {
return {
...requirement,
acceptanceCriteria: [...requirement.acceptanceCriteria],
impactedModules: [...requirement.impactedModules],
blockers: [...requirement.blockers],
stakeholders: [...requirement.stakeholders],
}
}
export const useRequirementsStore = defineStore('requirements', () => {
const requirements = ref<Requirement[]>([
{
id: 101,
reference: 'REQ-101',
uuid: '9f6c4f7a-9d2f-4dc0-8fa2-7ac2d79b0c11',
title: 'Full requirement traceability',
status: 'Approved',
priority: 'Critical',
owner: 'Claire Martin',
progress: 86,
dueDate: 'May 21, 2026',
description:
'Each requirement must be linked to a source, a validation status, and the design or test artifacts that depend on it.',
rationale:
'The team must be able to audit functional decisions, reduce scope drift, and prepare compliance reviews.',
acceptanceCriteria: [
'The source for each requirement is visible in the detail view.',
'The validation status is recorded in the history.',
'Links to related test cases are available from the detail panel.',
],
impactedModules: ['Backlog', 'Tests', 'Audit', 'Reporting'],
blockers: [],
notes: 'Plan a PDF export for milestone reviews.',
stakeholders: ['QA Team', 'Product Owner', 'Tech Lead'],
flexibility: 'Low—cannot alter core traceability chain without major rework',
tolerances: 'Maximum 5% of requirements can have incomplete artifact links',
},
{
id: 102,
reference: 'REQ-102',
uuid: '8aa1b6f5-2378-4c47-b490-2b92c7ee8b5f',
title: 'Business status management',
status: 'In Review',
priority: 'High',
owner: 'Julien Bernard',
progress: 54,
dueDate: 'May 24, 2026',
description:
'The product must provide consistent statuses to track a requirement from creation to delivery.',
rationale:
'Business and product teams do not use the same words at the same time. A stable naming scheme avoids ambiguity.',
acceptanceCriteria: [
'Statuses are readable at a glance.',
'The status list is consistent across the application.',
'Blocked items are visually highlighted.',
],
impactedModules: ['Workflow', 'Detail', 'Dashboard'],
blockers: ['Final label approval from the Product Owner'],
notes: 'Can be connected to workflow transitions later.',
stakeholders: ['Product Managers', 'Business Analysts', 'UI Team'],
flexibility: 'Medium—limited room for additional statuses, current labels are firm',
tolerances: 'Terminology must match approved glossary within 95% confidence',
},
{
id: 103,
reference: 'REQ-103',
uuid: '6f99d740-67bf-4a1f-88f2-4fd947f9cb8a',
title: 'Fast search and filtering',
status: 'Draft',
priority: 'Medium',
owner: 'Nadia Petit',
progress: 28,
dueDate: 'May 29, 2026',
description:
'Users must be able to find a requirement by title, reference, owner, or functional keyword.',
rationale:
'Once the project reaches a moderate volume, manual navigation becomes too expensive and slows down reviews.',
acceptanceCriteria: [
'Search filters the list instantly.',
'The number of visible items remains clear.',
'No result state is displayed explicitly.',
],
impactedModules: ['Sidebar', 'Search', 'Performance'],
blockers: ['Definition of the priority search criteria'],
notes: 'To be extended with type and batch filters.',
stakeholders: ['End Users', 'Performance Team'],
flexibility: 'High—can adapt search algorithm and UI patterns to user feedback',
tolerances: 'Search response time must stay under 200ms for 99% of queries',
},
{
id: 104,
reference: 'REQ-104',
uuid: 'f47e5f4a-c54d-46c9-8fef-f5f56e5e4d7a',
title: 'Delivery scope by release',
status: 'Blocked',
priority: 'Critical',
owner: 'Sophie Laurent',
progress: 41,
dueDate: 'May 31, 2026',
description:
'Each requirement must be linked to a target release so batch and delivery decisions can be prepared.',
rationale:
'Decision makers need a concise view to balance load and dependencies across releases.',
acceptanceCriteria: [
'The target release appears in the interface.',
'Blocked requirements are visually identified.',
'The delivery batch is readable in the detail view.',
],
impactedModules: ['Release', 'Roadmap', 'Governance'],
blockers: ['Architecture decision pending'],
notes: 'To be linked to a milestone calendar.',
stakeholders: ['Release Manager', 'Governance Board', 'Architects'],
flexibility: 'Low—release scope is constrained by business commitments',
tolerances: '100% of deliverables must link to a release before code freeze',
},
])
const selectedId = ref(101)
const searchQuery = ref('')
const selectedRequirement = computed<Requirement>(() => {
const fallback = requirements.value[0]
const found = requirements.value.find((requirement) => requirement.id === selectedId.value)
return found ?? fallback!
})
const stats = computed(() => {
const total = requirements.value.length
const approvedCount = requirements.value.filter(
(requirement) => requirement.status === 'Approved'
).length
const blockedCount = requirements.value.filter(
(requirement) => requirement.status === 'Blocked'
).length
const criticalCount = requirements.value.filter(
(requirement) => requirement.priority === 'Critical'
).length
return { total, approvedCount, blockedCount, criticalCount }
})
const getRequirementById = (id: number) => {
return requirements.value.find((requirement) => requirement.id === id) ?? null
}
const selectRequirement = (nextId: number) => {
const nextRequirement = getRequirementById(nextId)
if (!nextRequirement) {
return null
}
selectedId.value = nextId
return nextRequirement
}
const setRequirements = (nextRequirements: Requirement[]) => {
requirements.value = nextRequirements.map(cloneRequirement)
if (requirements.value.length === 0) {
return []
}
if (!getRequirementById(selectedId.value)) {
selectedId.value = requirements.value[0]!.id
}
return requirements.value
}
const updateRequirement = (updatedRequirement: Requirement) => {
const index = requirements.value.findIndex(
(requirement) => requirement.id === updatedRequirement.id
)
if (index === -1) {
return null
}
requirements.value[index] = cloneRequirement(updatedRequirement)
return requirements.value[index]
}
return {
requirements,
selectedId,
searchQuery,
selectedRequirement,
stats,
getRequirementById,
selectRequirement,
setRequirements,
updateRequirement,
}
})

View File

@@ -1,9 +1,58 @@
<script setup lang="ts">
import TheWelcome from '../components/TheWelcome.vue'
</script>
<template>
<main>
<TheWelcome />
</main>
<section class="home-view">
<section class="home-panel">
<p class="eyebrow">Home</p>
<h1>Requirements Workspace</h1>
<p class="subtitle">
Use the toolbar icons to switch between this home page and the requirements dashboard.
</p>
</section>
</section>
</template>
<style scoped>
.home-view {
display: flex;
height: 100%;
padding: 0;
background:
radial-gradient(circle at top left, rgba(78, 107, 255, 0.18), transparent 34%),
radial-gradient(circle at top right, rgba(21, 184, 164, 0.14), transparent 28%),
linear-gradient(180deg, rgba(245, 248, 255, 1) 0%, rgba(235, 241, 250, 1) 100%);
}
.home-panel {
flex: 1;
min-height: 0;
border: none;
background: rgba(255, 255, 255, 0.86);
box-shadow: 0 24px 60px rgba(34, 49, 77, 0.12);
backdrop-filter: blur(18px);
padding: 1.5rem;
}
.eyebrow {
margin: 0;
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 0.72rem;
font-weight: 700;
color: #5c6b88;
}
h1 {
margin: 0.5rem 0 0;
color: #12213a;
}
.subtitle {
margin-top: 0.75rem;
color: #4c5d77;
}
@media (max-width: 760px) {
.home-panel {
padding: 1rem;
}
}
</style>

View File

@@ -0,0 +1,272 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { storeToRefs } from 'pinia'
import Dialog from 'primevue/dialog'
import Splitter from 'primevue/splitter'
import SplitterPanel from 'primevue/splitterpanel'
import RequirementsTreeList from '../components/RequirementsTreeList.vue'
import RequirementDetail from '../components/RequirementDetail.vue'
import { useRequirementsStore } from '../stores/requirements'
import type { Requirement } from '../stores/requirements'
import { backendBridge } from '../bridge/backendBridge'
import type { TreeNode } from '../components/RequirementsTreeList.vue'
interface RequirementsListResponse {
requirements: Requirement[]
selectedId?: number
}
const requirementsStore = useRequirementsStore()
const { requirements, selectedId, searchQuery, selectedRequirement, stats } =
storeToRefs(requirementsStore)
const hasUnsavedChanges = ref(false)
const showTimeoutDialog = ref(false)
const splitterDraggingClass = 'is-splitter-dragging'
const onSplitterResizeStart = () => {
document.body.classList.add(splitterDraggingClass)
}
const onSplitterResizeEnd = () => {
document.body.classList.remove(splitterDraggingClass)
}
onBeforeUnmount(() => {
document.body.classList.remove(splitterDraggingClass)
})
const loadRequirementsFromBackend = async () => {
try {
const response = await backendBridge.sendRequest<RequirementsListResponse>('requirements.list', {}, {
timeoutMs: 5000,
})
if (!response || !Array.isArray(response.requirements)) {
return
}
requirementsStore.setRequirements(response.requirements)
if (typeof response.selectedId === 'number') {
requirementsStore.selectRequirement(response.selectedId)
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (message.toLowerCase().includes('timed out')) {
showTimeoutDialog.value = true
return
}
console.warn('Failed to load requirements from backend:', error)
}
}
onMounted(() => {
void loadRequirementsFromBackend()
})
const buildTreeData = (): TreeNode[] => {
return [
{
key: 'SECT-ELE',
label: 'Electronics',
icon: 'pi pi-fw pi-bolt',
children: [
{
key: '101',
label: `${requirements.value[0]!.reference} - ${requirements.value[0]!.title}`,
data: requirements.value[0]!,
},
{
key: '102-child',
label: 'Sub-requirement: Power management',
data: { ...requirements.value[1]!, id: 1021, reference: 'REQ-102.1' } as Requirement,
},
],
},
{
key: 'SECT-INFO',
label: 'Software',
icon: 'pi pi-fw pi-code',
children: [
{
key: '102',
label: `${requirements.value[1]!.reference} - ${requirements.value[1]!.title}`,
data: requirements.value[1]!,
},
{
key: '103',
label: `${requirements.value[2]!.reference} - ${requirements.value[2]!.title}`,
data: requirements.value[2]!,
},
],
},
{
key: 'SECT-MEC',
label: 'Mechanical',
icon: 'pi pi-fw pi-cog',
children: [
{
key: '104',
label: `${requirements.value[3]!.reference} - ${requirements.value[3]!.title}`,
data: requirements.value[3]!,
},
],
},
]
}
const treeData = computed(() => buildTreeData())
const onSaveRequirement = (updatedRequirement: Requirement) => {
requirementsStore.updateRequirement(updatedRequirement)
hasUnsavedChanges.value = false
}
const onDetailDirtyChange = (value: boolean) => {
hasUnsavedChanges.value = value
}
const onSelectRequirement = (nextId: number) => {
if (nextId === selectedId.value) {
return
}
if (hasUnsavedChanges.value) {
const confirmed = window.confirm(
'You have unsaved changes. Switching requirement will discard them. Continue?'
)
if (!confirmed) {
return
}
}
hasUnsavedChanges.value = false
requirementsStore.selectRequirement(nextId)
}
</script>
<template>
<section class="requirements-view">
<Dialog
v-model:visible="showTimeoutDialog"
header="Backend timeout"
modal
:closable="true"
:draggable="false"
:style="{ width: '28rem' }"
>
<p class="timeout-message">
The retrieval of requirements has timed out. Please check the connection with the backend and try again.
</p>
</Dialog>
<Splitter
class="workspace-splitter"
:gutter-size="6"
@resizestart="onSplitterResizeStart"
@resizeend="onSplitterResizeEnd"
>
<SplitterPanel :size="28" :min-size="20">
<RequirementsTreeList
:tree-data="treeData"
:searchQuery="searchQuery"
:selectedId="selectedId"
:stats="stats"
@update:searchQuery="searchQuery = $event"
@update:selectedId="onSelectRequirement"
/>
</SplitterPanel>
<SplitterPanel :size="72" :min-size="35">
<RequirementDetail
:requirement="selectedRequirement"
@save="onSaveRequirement"
@dirty-change="onDetailDirtyChange"
/>
</SplitterPanel>
</Splitter>
</section>
</template>
<style scoped>
.requirements-view {
display: flex;
flex-direction: column;
padding: 0;
min-height: 100%;
background:
radial-gradient(circle at top left, rgba(78, 107, 255, 0.18), transparent 34%),
radial-gradient(circle at top right, rgba(21, 184, 164, 0.14), transparent 28%),
linear-gradient(180deg, rgba(245, 248, 255, 1) 0%, rgba(235, 241, 250, 1) 100%);
}
.workspace-splitter {
flex: 1;
height: 100%;
min-height: 0;
}
.workspace-splitter :deep(.p-splitter-panel) {
min-height: 0;
}
.timeout-message {
margin: 0;
color: #3f4d68;
}
.workspace-splitter :deep(.p-splitter-gutter) {
position: relative;
background: transparent;
cursor: col-resize;
}
.workspace-splitter :deep(.p-splitter-gutter::before) {
content: '';
position: absolute;
top: 8px;
bottom: 8px;
left: 50%;
width: 2px;
transform: translateX(-50%);
border-radius: 999px;
background: rgba(96, 117, 156, 0.32);
transition: background-color 0.2s ease;
}
.workspace-splitter :deep(.p-splitter-gutter:hover::before),
.workspace-splitter :deep(.p-splitter-gutter:active::before) {
background: rgba(78, 107, 255, 0.6);
}
.workspace-splitter :deep(.p-splitter-gutter-handle) {
width: 0;
height: 0;
}
:global(body.is-splitter-dragging),
:global(body.is-splitter-dragging *) {
user-select: none;
-webkit-user-select: none;
}
@media (max-width: 1120px) {
.workspace-splitter {
height: 100%;
}
}
@media (max-width: 760px) {
.requirements-view {
padding: 0;
}
}
</style>