@kitsy/coop-ui 0.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ /*! tailwindcss v4.2.2 | MIT License | https://tailwindcss.com */
2
+ @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-500:oklch(63.7% .237 25.331);--color-red-700:oklch(50.5% .213 27.518);--color-stone-100:oklch(97% .001 106.424);--color-stone-200:oklch(92.3% .003 48.717);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-2xl:42rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--font-weight-medium:500;--font-weight-semibold:600;--tracking-tight:-.025em;--radius-2xl:1rem;--radius-3xl:1.5rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::-moz-placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::-moz-placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.mx-auto{margin-inline:auto}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-6{margin-top:calc(var(--spacing) * 6)}.block{display:block}.flex{display:flex}.grid{display:grid}.h-2{height:calc(var(--spacing) * 2)}.h-3{height:calc(var(--spacing) * 3)}.h-56{height:calc(var(--spacing) * 56)}.min-h-\[540px\]{min-height:540px}.min-h-screen{min-height:100vh}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-7xl{max-width:var(--container-7xl)}.min-w-\[960px\]{min-width:960px}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.overflow-auto{overflow:auto}.overflow-visible{overflow:visible}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-3xl{border-radius:var(--radius-3xl)}.rounded-\[2rem\]{border-radius:2rem}.rounded-full{border-radius:3.40282e38px}.border{border-style:var(--tw-border-style);border-width:1px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-black\/8{border-color:#00000014}@supports (color:color-mix(in lab, red, red)){.border-black\/8{border-color:color-mix(in oklab, var(--color-black) 8%, transparent)}}.border-black\/10{border-color:#0000001a}@supports (color:color-mix(in lab, red, red)){.border-black\/10{border-color:color-mix(in oklab, var(--color-black) 10%, transparent)}}.border-red-200{border-color:var(--color-red-200)}.border-red-300{border-color:var(--color-red-300)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-100{background-color:var(--color-red-100)}.bg-red-500{background-color:var(--color-red-500)}.bg-stone-100{background-color:var(--color-stone-100)}.bg-stone-200{background-color:var(--color-stone-200)}.bg-white{background-color:var(--color-white)}.bg-white\/70{background-color:#ffffffb3}@supports (color:color-mix(in lab, red, red)){.bg-white\/70{background-color:color-mix(in oklab, var(--color-white) 70%, transparent)}}.bg-white\/75{background-color:#ffffffbf}@supports (color:color-mix(in lab, red, red)){.bg-white\/75{background-color:color-mix(in oklab, var(--color-white) 75%, transparent)}}.bg-white\/80{background-color:#fffc}@supports (color:color-mix(in lab, red, red)){.bg-white\/80{background-color:color-mix(in oklab, var(--color-white) 80%, transparent)}}.bg-white\/85{background-color:#ffffffd9}@supports (color:color-mix(in lab, red, red)){.bg-white\/85{background-color:color-mix(in oklab, var(--color-white) 85%, transparent)}}.bg-gradient-to-r{--tw-gradient-position:to right in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.p-10{padding:calc(var(--spacing) * 10)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-6{padding-block:calc(var(--spacing) * 6)}.text-center{text-align:center}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12px\]{font-size:12px}.text-\[14px\]{font-size:14px}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.14em\]{--tw-tracking:.14em;letter-spacing:.14em}.tracking-\[0\.18em\]{--tw-tracking:.18em;letter-spacing:.18em}.tracking-\[0\.22em\]{--tw-tracking:.22em;letter-spacing:.22em}.tracking-\[0\.28em\]{--tw-tracking:.28em;letter-spacing:.28em}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.text-red-700{color:var(--color-red-700)}.text-white{color:var(--color-white)}.uppercase{text-transform:uppercase}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (hover:hover){.hover\:bg-stone-100:hover{background-color:var(--color-stone-100)}}@media (width>=40rem){.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}}@media (width>=64rem){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (width>=64rem){.lg\:flex-row{flex-direction:row}}@media (width>=64rem){.lg\:items-end{align-items:flex-end}}@media (width>=64rem){.lg\:justify-between{justify-content:space-between}}@media (width>=64rem){.lg\:px-10{padding-inline:calc(var(--spacing) * 10)}}@media (width>=80rem){.xl\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}}@media (width>=80rem){.xl\:grid-cols-\[1\.1fr\,0\.9fr\]{grid-template-columns:1.1fr,.9fr}}}:root{color:#111318;background:radial-gradient(circle at 0 0,#d96b2b2e,#0000 34%),radial-gradient(circle at 100% 0,#29594a26,#0000 28%),linear-gradient(#f4efe2 0%,#f7f4eb 100%);font-family:IBM Plex Sans,Segoe UI,sans-serif}body{min-width:320px;min-height:100vh;margin:0}#root{min-height:100vh}.card-grid{grid-template-columns:repeat(auto-fit,minmax(160px,1fr))}.metric-grid{grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>COOP Dashboard</title>
7
+ <script type="module" crossorigin src="/assets/index-5wlk1Z4Y.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-Bh8XsSoO.css">
9
+ </head>
10
+ <body class="bg-paper text-ink">
11
+ <div id="root"></div>
12
+ </body>
13
+ </html>
package/index.html ADDED
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>COOP Dashboard</title>
7
+ </head>
8
+ <body class="bg-paper text-ink">
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/main.tsx"></script>
11
+ </body>
12
+ </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kitsy/coop-ui",
3
- "version": "0.0.1",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -10,16 +10,36 @@
10
10
  "import": "./src/index.ts"
11
11
  }
12
12
  },
13
+ "files": [
14
+ "dist",
15
+ "src",
16
+ "index.html",
17
+ "vite.config.ts",
18
+ "postcss.config.cjs",
19
+ "tailwind.config.ts"
20
+ ],
13
21
  "dependencies": {
14
- "@kitsy/coop-core": "0.0.1"
22
+ "@tailwindcss/postcss": "^4.2.1",
23
+ "@vitejs/plugin-react": "^6.0.1",
24
+ "autoprefixer": "^10.4.27",
25
+ "dagre": "^0.8.5",
26
+ "postcss": "^8.5.8",
27
+ "react": "^19.2.4",
28
+ "react-dom": "^19.2.4",
29
+ "tailwindcss": "^4.2.1",
30
+ "vite": "^8.0.0",
31
+ "@kitsy/coop-core": "1.0.0"
15
32
  },
16
33
  "devDependencies": {
34
+ "@types/dagre": "^0.7.54",
17
35
  "@types/node": "^24.12.0",
18
- "tsup": "^8.5.1",
36
+ "@types/react": "^19.2.14",
37
+ "@types/react-dom": "^19.2.3",
19
38
  "typescript": "^5.9.3"
20
39
  },
21
40
  "scripts": {
22
- "build": "tsup src/index.ts --format esm,cjs --dts --clean",
41
+ "dev": "vite",
42
+ "build": "vite build",
23
43
  "typecheck": "tsc -p tsconfig.json --noEmit"
24
44
  }
25
45
  }
@@ -0,0 +1,6 @@
1
+ module.exports = {
2
+ plugins: {
3
+ "@tailwindcss/postcss": {},
4
+ autoprefixer: {},
5
+ },
6
+ };
package/src/app.tsx ADDED
@@ -0,0 +1,459 @@
1
+ import { useEffect, useMemo, useState } from "react";
2
+ import dagre from "dagre";
3
+
4
+ import type { UiDelivery, UiGraphEdge, UiGraphNode, UiSnapshot, UiTask } from "./types";
5
+
6
+ type TabKey = "dashboard" | "kanban" | "delivery" | "graph";
7
+
8
+ type FetchState =
9
+ | { kind: "loading" }
10
+ | { kind: "error"; message: string }
11
+ | { kind: "ready"; snapshot: UiSnapshot };
12
+
13
+ type LayoutNode = UiGraphNode & { x: number; y: number; width: number; height: number };
14
+ type LayoutEdge = UiGraphEdge & { points: Array<{ x: number; y: number }> };
15
+
16
+ const STATUS_ORDER = ["todo", "blocked", "in_progress", "in_review", "done", "canceled"];
17
+ const TAB_LABELS: Record<TabKey, string> = {
18
+ dashboard: "Dashboard",
19
+ kanban: "Kanban",
20
+ delivery: "Delivery",
21
+ graph: "Graph"
22
+ };
23
+
24
+ function classNames(...values: Array<string | false | null | undefined>): string {
25
+ return values.filter(Boolean).join(" ");
26
+ }
27
+
28
+ function formatPercent(value: number): string {
29
+ return `${value.toFixed(1)}%`;
30
+ }
31
+
32
+ function healthTone(health: UiDelivery["health"]): string {
33
+ if (health === "healthy") return "bg-moss/15 text-moss border-moss/30";
34
+ if (health === "warning") return "bg-accent/12 text-accent border-accent/30";
35
+ return "bg-red-100 text-red-700 border-red-300";
36
+ }
37
+
38
+ function statusTone(status: string): string {
39
+ if (status === "done") return "bg-moss/15 text-moss";
40
+ if (status === "in_progress" || status === "in_review") return "bg-accent/12 text-accent";
41
+ if (status === "blocked") return "bg-red-100 text-red-700";
42
+ return "bg-steel/10 text-steel";
43
+ }
44
+
45
+ function chartPath(points: Array<{ x: number; y: number }>): string {
46
+ return points.map((point, index) => `${index === 0 ? "M" : "L"}${point.x},${point.y}`).join(" ");
47
+ }
48
+
49
+ function buildLinePoints(points: Array<{ label: string; remaining: number }>, width: number, height: number) {
50
+ if (points.length === 0) return [];
51
+ const maxValue = Math.max(...points.map((point) => point.remaining), 1);
52
+ return points.map((point, index) => ({
53
+ x: points.length === 1 ? width / 2 : (index / (points.length - 1)) * width,
54
+ y: height - (point.remaining / maxValue) * height
55
+ }));
56
+ }
57
+
58
+ function layoutGraph(nodes: UiGraphNode[], edges: UiGraphEdge[]) {
59
+ const graph = new dagre.graphlib.Graph();
60
+ graph.setGraph({ rankdir: "LR", nodesep: 28, ranksep: 72, marginx: 24, marginy: 24 });
61
+ graph.setDefaultEdgeLabel(() => ({}));
62
+ for (const node of nodes) {
63
+ graph.setNode(node.id, { width: 180, height: 76 });
64
+ }
65
+ for (const edge of edges) {
66
+ graph.setEdge(edge.from, edge.to);
67
+ }
68
+ dagre.layout(graph);
69
+
70
+ const layoutNodes: LayoutNode[] = nodes.map((node) => {
71
+ const position = graph.node(node.id);
72
+ return {
73
+ ...node,
74
+ x: position?.x ?? 0,
75
+ y: position?.y ?? 0,
76
+ width: 180,
77
+ height: 76
78
+ };
79
+ });
80
+ const layoutEdges: LayoutEdge[] = edges.map((edge) => ({
81
+ ...edge,
82
+ points: (graph.edge(edge.from, edge.to)?.points ?? []) as Array<{ x: number; y: number }>
83
+ }));
84
+ const width = Math.max(...layoutNodes.map((node) => node.x + node.width / 2), 960);
85
+ const height = Math.max(...layoutNodes.map((node) => node.y + node.height / 2), 520);
86
+ return { nodes: layoutNodes, edges: layoutEdges, width, height };
87
+ }
88
+
89
+ function MetricCard(props: { label: string; value: string; note?: string }) {
90
+ return (
91
+ <div className="rounded-3xl border border-black/8 bg-white/80 p-5 shadow-panel backdrop-blur">
92
+ <div className="text-xs uppercase tracking-[0.22em] text-steel">{props.label}</div>
93
+ <div className="mt-3 text-3xl font-semibold text-ink">{props.value}</div>
94
+ {props.note ? <div className="mt-2 text-sm text-steel">{props.note}</div> : null}
95
+ </div>
96
+ );
97
+ }
98
+
99
+ function BurndownChart(props: { points: Array<{ label: string; remaining: number }> }) {
100
+ const width = 480;
101
+ const height = 180;
102
+ const points = buildLinePoints(props.points, width, height);
103
+ return (
104
+ <div className="rounded-3xl border border-black/8 bg-white/80 p-5 shadow-panel">
105
+ <div>
106
+ <div className="text-xs uppercase tracking-[0.22em] text-steel">Burndown</div>
107
+ <div className="mt-1 text-sm text-steel">Remaining delivery scope over recorded completions.</div>
108
+ </div>
109
+ <svg viewBox={`0 0 ${width} ${height + 28}`} className="mt-5 h-56 w-full overflow-visible">
110
+ <path d={chartPath(points)} fill="none" stroke="#d96b2b" strokeWidth="3" strokeLinecap="round" />
111
+ {points.map((point, index) => (
112
+ <g key={`${props.points[index]?.label}-${point.x}`}>
113
+ <circle cx={point.x} cy={point.y} r="5" fill="#29594a" />
114
+ <text x={point.x} y={height + 18} textAnchor="middle" className="fill-steel text-[10px]">
115
+ {props.points[index]?.label}
116
+ </text>
117
+ </g>
118
+ ))}
119
+ </svg>
120
+ </div>
121
+ );
122
+ }
123
+
124
+ function GraphCanvas(props: { nodes: UiGraphNode[]; edges: UiGraphEdge[]; criticalPath?: string[] }) {
125
+ const { nodes, edges, width, height } = useMemo(() => layoutGraph(props.nodes, props.edges), [props.edges, props.nodes]);
126
+ const critical = new Set(props.criticalPath ?? []);
127
+ return (
128
+ <div className="overflow-auto rounded-3xl border border-black/8 bg-white/85 p-4 shadow-panel">
129
+ <svg viewBox={`0 0 ${width} ${height}`} className="min-h-[540px] min-w-[960px]">
130
+ {edges.map((edge) => (
131
+ <polyline
132
+ key={`${edge.from}-${edge.to}`}
133
+ points={edge.points.map((point) => `${point.x},${point.y}`).join(" ")}
134
+ fill="none"
135
+ stroke={critical.has(edge.from) && critical.has(edge.to) ? "#d96b2b" : "#8a94a3"}
136
+ strokeWidth={critical.has(edge.from) && critical.has(edge.to) ? 3.5 : 2}
137
+ strokeLinejoin="round"
138
+ strokeLinecap="round"
139
+ />
140
+ ))}
141
+ {nodes.map((node) => {
142
+ const x = node.x - node.width / 2;
143
+ const y = node.y - node.height / 2;
144
+ return (
145
+ <g key={node.id} transform={`translate(${x}, ${y})`}>
146
+ <rect
147
+ rx="20"
148
+ ry="20"
149
+ width={node.width}
150
+ height={node.height}
151
+ fill={critical.has(node.id) ? "#fff2e8" : "white"}
152
+ stroke={critical.has(node.id) ? "#d96b2b" : "#d4d4d8"}
153
+ strokeWidth={critical.has(node.id) ? 3 : 1.5}
154
+ />
155
+ <text x="18" y="24" className="fill-steel text-[10px] uppercase tracking-[0.18em]">
156
+ {node.status.replace(/_/g, " ")}
157
+ </text>
158
+ <text x="18" y="45" className="fill-ink text-[14px] font-semibold">
159
+ {node.id}
160
+ </text>
161
+ <text x="18" y="62" className="fill-steel text-[12px]">
162
+ {node.title.slice(0, 22)}
163
+ </text>
164
+ </g>
165
+ );
166
+ })}
167
+ </svg>
168
+ </div>
169
+ );
170
+ }
171
+
172
+ function DashboardPage(props: { snapshot: UiSnapshot }) {
173
+ const atRisk = props.snapshot.deliveries.filter((delivery) => delivery.health === "at-risk").length;
174
+ const warning = props.snapshot.deliveries.filter((delivery) => delivery.health === "warning").length;
175
+ return (
176
+ <div className="space-y-6">
177
+ <section className="grid card-grid gap-4">
178
+ {props.snapshot.statusCounts.map((entry) => (
179
+ <MetricCard key={entry.status} label={entry.status.replace(/_/g, " ")} value={String(entry.count)} />
180
+ ))}
181
+ </section>
182
+ <section className="grid metric-grid gap-4">
183
+ <MetricCard label="Velocity" value={`${props.snapshot.velocity.tasksPerWeek}/wk`} note={`${props.snapshot.velocity.hoursPerWeek}h per week`} />
184
+ <MetricCard label="Trend" value={props.snapshot.velocity.trend} note={`Accuracy ratio ${props.snapshot.velocity.accuracyRatio}`} />
185
+ <MetricCard label="At Risk" value={String(atRisk)} note={`${warning} deliveries in warning state`} />
186
+ </section>
187
+ <section className="grid gap-4 lg:grid-cols-2">
188
+ {props.snapshot.deliveries.map((delivery) => (
189
+ <article key={delivery.id} className="rounded-3xl border border-black/8 bg-white/80 p-6 shadow-panel">
190
+ <div className="flex items-start justify-between gap-4">
191
+ <div>
192
+ <div className="text-xs uppercase tracking-[0.22em] text-steel">{delivery.id}</div>
193
+ <h2 className="mt-2 text-2xl font-semibold">{delivery.name}</h2>
194
+ </div>
195
+ <span className={classNames("rounded-full border px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em]", healthTone(delivery.health))}>
196
+ {delivery.health}
197
+ </span>
198
+ </div>
199
+ <div className="mt-5 grid grid-cols-2 gap-4 text-sm text-steel">
200
+ <div>
201
+ <div>Target</div>
202
+ <div className="mt-1 font-medium text-ink">{delivery.targetDate ?? "-"}</div>
203
+ </div>
204
+ <div>
205
+ <div>Projected</div>
206
+ <div className="mt-1 font-medium text-ink">{delivery.projectedDate ?? "-"}</div>
207
+ </div>
208
+ <div>
209
+ <div>Completion</div>
210
+ <div className="mt-1 font-medium text-ink">{formatPercent(delivery.completionPercent)}</div>
211
+ </div>
212
+ <div>
213
+ <div>Open Risks</div>
214
+ <div className="mt-1 font-medium text-ink">{delivery.riskMessages.length}</div>
215
+ </div>
216
+ </div>
217
+ <div className="mt-5 h-2 rounded-full bg-stone-200">
218
+ <div className="h-2 rounded-full bg-gradient-to-r from-moss to-accent" style={{ width: `${Math.min(delivery.completionPercent, 100)}%` }} />
219
+ </div>
220
+ </article>
221
+ ))}
222
+ </section>
223
+ </div>
224
+ );
225
+ }
226
+
227
+ function KanbanPage(props: { tasks: UiTask[] }) {
228
+ return (
229
+ <div className="grid gap-4 xl:grid-cols-6">
230
+ {STATUS_ORDER.map((status) => {
231
+ const tasks = props.tasks.filter((task) => task.status === status);
232
+ return (
233
+ <section key={status} className="rounded-3xl border border-black/8 bg-white/75 p-4 shadow-panel">
234
+ <div className="flex items-center justify-between">
235
+ <h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-steel">{status.replace(/_/g, " ")}</h2>
236
+ <span className="rounded-full bg-stone-100 px-2 py-1 text-xs text-steel">{tasks.length}</span>
237
+ </div>
238
+ <div className="mt-4 space-y-3">
239
+ {tasks.map((task) => (
240
+ <article key={task.id} className="rounded-2xl border border-black/8 bg-paper/70 p-3">
241
+ <div className="flex items-center justify-between gap-3">
242
+ <div className="font-mono text-xs text-steel">{task.id}</div>
243
+ <span className={classNames("rounded-full px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.14em]", statusTone(task.status))}>
244
+ {task.status.replace(/_/g, " ")}
245
+ </span>
246
+ </div>
247
+ <div className="mt-2 text-sm font-semibold text-ink">{task.title}</div>
248
+ <div className="mt-3 flex flex-wrap gap-2 text-[11px] text-steel">
249
+ {task.track ? <span className="rounded-full bg-white px-2 py-1">{task.track}</span> : null}
250
+ {task.priority ? <span className="rounded-full bg-white px-2 py-1">{task.priority}</span> : null}
251
+ {task.deliveryIds.map((deliveryId) => (
252
+ <span key={`${task.id}-${deliveryId}`} className="rounded-full bg-white px-2 py-1">
253
+ {deliveryId}
254
+ </span>
255
+ ))}
256
+ </div>
257
+ </article>
258
+ ))}
259
+ {tasks.length === 0 ? <div className="rounded-2xl border border-dashed border-black/10 px-3 py-6 text-center text-sm text-steel">No tasks</div> : null}
260
+ </div>
261
+ </section>
262
+ );
263
+ })}
264
+ </div>
265
+ );
266
+ }
267
+
268
+ function DeliveryPage(props: { deliveries: UiDelivery[]; graph: UiSnapshot["graph"] }) {
269
+ const [selectedId, setSelectedId] = useState<string>(props.deliveries[0]?.id ?? "");
270
+ const selected = props.deliveries.find((delivery) => delivery.id === selectedId) ?? props.deliveries[0];
271
+ const criticalNodeIds = new Set(selected?.criticalPath ?? []);
272
+ const nodes = criticalNodeIds.size > 0 ? props.graph.nodes.filter((node) => criticalNodeIds.has(node.id)) : props.graph.nodes;
273
+ const edges = criticalNodeIds.size > 0
274
+ ? props.graph.edges.filter((edge) => criticalNodeIds.has(edge.from) && criticalNodeIds.has(edge.to))
275
+ : props.graph.edges;
276
+
277
+ if (!selected) {
278
+ return <div className="rounded-3xl border border-dashed border-black/10 p-8 text-center text-steel">No deliveries available.</div>;
279
+ }
280
+
281
+ return (
282
+ <div className="space-y-6">
283
+ <div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
284
+ <div>
285
+ <div className="text-xs uppercase tracking-[0.22em] text-steel">Delivery Focus</div>
286
+ <h2 className="mt-2 text-3xl font-semibold">{selected.name}</h2>
287
+ </div>
288
+ <label className="text-sm text-steel">
289
+ Delivery
290
+ <select value={selected.id} onChange={(event) => setSelectedId(event.target.value)} className="mt-2 block rounded-2xl border border-black/10 bg-white px-4 py-3 text-ink">
291
+ {props.deliveries.map((delivery) => (
292
+ <option key={delivery.id} value={delivery.id}>
293
+ {delivery.id} - {delivery.name}
294
+ </option>
295
+ ))}
296
+ </select>
297
+ </label>
298
+ </div>
299
+ <section className="grid metric-grid gap-4">
300
+ <MetricCard label="Completion" value={formatPercent(selected.completionPercent)} note={`${selected.completedTasks}/${selected.totalTasks} tasks done`} />
301
+ <MetricCard label="Schedule" value={selected.projectedDate ?? "-"} note={`Target ${selected.targetDate ?? "-"}`} />
302
+ <MetricCard label="Effort" value={`${selected.requiredHours}h`} note={selected.budgetHours ? `Budget ${selected.budgetHours}h` : "No budget set"} />
303
+ </section>
304
+ <div className="grid gap-6 xl:grid-cols-[1.1fr,0.9fr]">
305
+ <BurndownChart points={selected.burndown} />
306
+ <section className="rounded-3xl border border-black/8 bg-white/80 p-5 shadow-panel">
307
+ <div className="text-xs uppercase tracking-[0.22em] text-steel">Critical Path</div>
308
+ <div className="mt-4 flex flex-wrap gap-3">
309
+ {selected.criticalPath.map((taskId) => (
310
+ <span key={taskId} className="rounded-full border border-accent/30 bg-accent/10 px-3 py-2 text-sm font-medium text-accent">
311
+ {taskId}
312
+ </span>
313
+ ))}
314
+ </div>
315
+ <div className="mt-6 space-y-3">
316
+ <div className="text-xs uppercase tracking-[0.22em] text-steel">Risk Signals</div>
317
+ {selected.riskMessages.length === 0 ? (
318
+ <div className="rounded-2xl bg-moss/8 px-4 py-3 text-sm text-moss">No active risks detected.</div>
319
+ ) : (
320
+ selected.riskMessages.map((risk) => (
321
+ <div key={risk} className="rounded-2xl bg-red-50 px-4 py-3 text-sm text-red-700">
322
+ {risk}
323
+ </div>
324
+ ))
325
+ )}
326
+ </div>
327
+ </section>
328
+ </div>
329
+ <section className="rounded-3xl border border-black/8 bg-white/80 p-5 shadow-panel">
330
+ <div className="text-xs uppercase tracking-[0.22em] text-steel">Capacity Pressure</div>
331
+ <div className="mt-4 space-y-4">
332
+ {selected.utilization.map((item) => (
333
+ <div key={item.track}>
334
+ <div className="flex items-center justify-between text-sm">
335
+ <span className="font-medium text-ink">{item.track}</span>
336
+ <span className="text-steel">{item.allocatedHours}h / {item.capacityHours}h</span>
337
+ </div>
338
+ <div className="mt-2 h-3 rounded-full bg-stone-200">
339
+ <div className={classNames("h-3 rounded-full", item.utilization >= 100 ? "bg-red-500" : item.utilization >= 85 ? "bg-accent" : "bg-moss")} style={{ width: `${Math.min(item.utilization, 100)}%` }} />
340
+ </div>
341
+ </div>
342
+ ))}
343
+ </div>
344
+ </section>
345
+ {selected.criticalPath.length > 0 ? <GraphCanvas nodes={nodes} edges={edges} criticalPath={selected.criticalPath} /> : null}
346
+ </div>
347
+ );
348
+ }
349
+
350
+ function GraphPage(props: { snapshot: UiSnapshot }) {
351
+ const [filter, setFilter] = useState<string>("all");
352
+ const selectedDelivery = props.snapshot.deliveries.find((delivery) => delivery.id === filter);
353
+ const nodeSet = filter === "all" ? null : new Set(props.snapshot.tasks.filter((task) => task.deliveryIds.includes(filter)).map((task) => task.id));
354
+ const nodes = nodeSet ? props.snapshot.graph.nodes.filter((node) => nodeSet.has(node.id)) : props.snapshot.graph.nodes;
355
+ const edges = nodeSet ? props.snapshot.graph.edges.filter((edge) => nodeSet.has(edge.from) && nodeSet.has(edge.to)) : props.snapshot.graph.edges;
356
+
357
+ return (
358
+ <div className="space-y-6">
359
+ <div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
360
+ <div>
361
+ <div className="text-xs uppercase tracking-[0.22em] text-steel">Dependency Graph</div>
362
+ <h2 className="mt-2 text-3xl font-semibold">Task network viewer</h2>
363
+ </div>
364
+ <label className="text-sm text-steel">
365
+ Scope
366
+ <select value={filter} onChange={(event) => setFilter(event.target.value)} className="mt-2 block rounded-2xl border border-black/10 bg-white px-4 py-3 text-ink">
367
+ <option value="all">All tasks</option>
368
+ {props.snapshot.deliveries.map((delivery) => (
369
+ <option key={delivery.id} value={delivery.id}>{delivery.id}</option>
370
+ ))}
371
+ </select>
372
+ </label>
373
+ </div>
374
+ <GraphCanvas nodes={nodes} edges={edges} criticalPath={selectedDelivery?.criticalPath} />
375
+ </div>
376
+ );
377
+ }
378
+
379
+ export function App() {
380
+ const [tab, setTab] = useState<TabKey>("dashboard");
381
+ const [state, setState] = useState<FetchState>({ kind: "loading" });
382
+
383
+ useEffect(() => {
384
+ let active = true;
385
+ fetch("/__coop/data")
386
+ .then(async (response) => {
387
+ if (!response.ok) {
388
+ const payload = (await response.json().catch(() => ({}))) as { message?: string };
389
+ throw new Error(payload.message ?? `Request failed with ${response.status}`);
390
+ }
391
+ return response.json() as Promise<UiSnapshot>;
392
+ })
393
+ .then((snapshot) => {
394
+ if (!active) return;
395
+ setState({ kind: "ready", snapshot });
396
+ })
397
+ .catch((error) => {
398
+ if (!active) return;
399
+ setState({ kind: "error", message: error instanceof Error ? error.message : String(error) });
400
+ });
401
+
402
+ return () => {
403
+ active = false;
404
+ };
405
+ }, []);
406
+
407
+ return (
408
+ <main className="min-h-screen px-4 py-6 sm:px-6 lg:px-10">
409
+ <div className="mx-auto max-w-7xl space-y-6">
410
+ <header className="rounded-[2rem] border border-black/8 bg-white/70 px-6 py-6 shadow-panel backdrop-blur">
411
+ <div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
412
+ <div>
413
+ <div className="text-xs uppercase tracking-[0.28em] text-steel">COOP Intelligence</div>
414
+ <h1 className="mt-2 text-4xl font-semibold tracking-tight text-ink">Local delivery dashboard</h1>
415
+ <p className="mt-2 max-w-2xl text-sm text-steel">
416
+ Read-only visibility over task status, delivery feasibility, capacity pressure, and dependency structure.
417
+ </p>
418
+ </div>
419
+ {state.kind === "ready" ? (
420
+ <div className="rounded-2xl bg-ink px-4 py-3 text-sm text-white">
421
+ <div className="font-mono text-xs uppercase tracking-[0.18em] text-sand">Repo</div>
422
+ <div className="mt-1">{state.snapshot.repoRoot}</div>
423
+ </div>
424
+ ) : null}
425
+ </div>
426
+ <nav className="mt-6 flex flex-wrap gap-3">
427
+ {(Object.keys(TAB_LABELS) as TabKey[]).map((entry) => (
428
+ <button key={entry} type="button" onClick={() => setTab(entry)} className={classNames("rounded-full px-4 py-2 text-sm font-semibold transition", tab === entry ? "bg-ink text-white" : "bg-white text-steel hover:bg-stone-100")}>
429
+ {TAB_LABELS[entry]}
430
+ </button>
431
+ ))}
432
+ </nav>
433
+ </header>
434
+
435
+ {state.kind === "loading" ? (
436
+ <section className="rounded-[2rem] border border-black/8 bg-white/80 p-10 text-center shadow-panel">
437
+ <div className="text-lg font-semibold">Loading COOP index...</div>
438
+ </section>
439
+ ) : null}
440
+
441
+ {state.kind === "error" ? (
442
+ <section className="rounded-[2rem] border border-red-200 bg-red-50 p-10 shadow-panel">
443
+ <div className="text-lg font-semibold text-red-700">Unable to load dashboard</div>
444
+ <div className="mt-3 text-sm text-red-700">{state.message}</div>
445
+ </section>
446
+ ) : null}
447
+
448
+ {state.kind === "ready" ? (
449
+ <section className="space-y-6">
450
+ {tab === "dashboard" ? <DashboardPage snapshot={state.snapshot} /> : null}
451
+ {tab === "kanban" ? <KanbanPage tasks={state.snapshot.tasks} /> : null}
452
+ {tab === "delivery" ? <DeliveryPage deliveries={state.snapshot.deliveries} graph={state.snapshot.graph} /> : null}
453
+ {tab === "graph" ? <GraphPage snapshot={state.snapshot} /> : null}
454
+ </section>
455
+ ) : null}
456
+ </div>
457
+ </main>
458
+ );
459
+ }
package/src/index.ts CHANGED
@@ -1,9 +1,2 @@
1
- export type CoopUiPlaceholder = {
2
- readonly package: "@kitsy/coop-ui";
3
- readonly status: "planned";
4
- };
5
-
6
- export const coopUi: CoopUiPlaceholder = {
7
- package: "@kitsy/coop-ui",
8
- status: "planned",
9
- };
1
+ export * from "./types";
2
+ export { readCoopSnapshot } from "./server/snapshot";
package/src/main.tsx ADDED
@@ -0,0 +1,11 @@
1
+ import React from "react";
2
+ import ReactDOM from "react-dom/client";
3
+
4
+ import { App } from "./app";
5
+ import "./styles.css";
6
+
7
+ ReactDOM.createRoot(document.getElementById("root")!).render(
8
+ <React.StrictMode>
9
+ <App />
10
+ </React.StrictMode>
11
+ );