14 Commits

Author SHA1 Message Date
KS Jannette
22667b38b8 add agents.md 2026-08-01 06:00:04 -04:00
e547dee979 Merge pull request 'Upgraded embedding model to voyage-3.5, updated README.md' (#5) from FEAT-update-voyage-model into master
Reviewed-on: #5
2026-08-01 09:35:43 +00:00
KS Jannette
362a47f88a Upgraded embedding model to voyage-3.5, updated README.md 2026-08-01 05:35:06 -04:00
274e846909 Merge pull request 'fix minor css issue on stickies' (#4) from minor-css-update into master
Reviewed-on: #4
2026-08-01 08:50:59 +00:00
KS Jannette
c6b07ccb56 fix minor css issue on stickies 2026-08-01 04:50:40 -04:00
KS Jannette
8e79e78006 hotfix 2026-08-01 04:41:31 -04:00
19d4a82c90 Merge pull request 'Re-aligned heuristic, updated readme, added code comment' (#3) from BUG-cohesion-scoreUI-display into master
Reviewed-on: #3
2026-08-01 08:13:29 +00:00
edeadd547a Update README.md 2026-08-01 08:11:10 +00:00
3b707a150b Update README.md 2026-08-01 08:08:34 +00:00
c2d40fa409 Update README.md 2026-08-01 08:08:19 +00:00
KS Jannette
6f09b6ecdc Re-aligned heuristic, updated readme, added code comment 2026-08-01 04:06:39 -04:00
ac75a30b61 Update README.md 2026-08-01 07:42:16 +00:00
f1f2a93e4a Merge pull request 'Updated readme and streams.js' (#2) from FEAT-update-README-with-roadmap into master
Reviewed-on: #2
2026-08-01 07:40:55 +00:00
KS Jannette
68fed1a56e Updated readme and streams.js 2026-08-01 03:39:22 -04:00
9 changed files with 377 additions and 268 deletions

View File

@@ -1,12 +1,57 @@
# kongruity # kongruity: Signal from noise
“...All those moments will be lost in time, like tears in rain.”
kongruity pulls in unstructured artifacts of the creative-engineering process -- to-dos, action items, agile tickets, Jira thread comments, Slack thread comments, retrospective notes -- and synthesizes them into semantically coherent, prioritized clusters that can be incorporated into implementation planning. kongruity pulls in unstructured artifacts of the creative-engineering process -- to-dos, action items, agile tickets, Jira thread comments, Slack thread comments, retrospective notes -- and synthesizes them into semantically coherent, prioritized clusters that can be incorporated into implementation planning.
In kongruity world, the artifacts become "sticky notes." A board full of them looks chaotic. With a click, the LLM analyzes their semantic meaning and groups them into thematic clusters, each with a descriptive header. In kongruity, the artifacts become "sticky notes." A board full of them looks chaotic.
## Result evalutation/validation: With a click, they are semantically evaluated, grouped into thematic clusters with descriptive headers, rankable and exportable to project planning and execution tools.
An independent RAG embedding-based model scores evaluates quality using cosine similiarty, so the output is qualitatively refined. From there, teams can drag-and-rank related task clusters by implementation priority - turning noise into an actionable workflow. ## Voyage AI voyage-3.5
![Embedding model benchmarking.](Voyage.jpg)
## Clustering and evaluation: methodology
Two models run in parallel, and neither sees the other's work. Anthropic's `claude-sonnet-5` (`backend/services/clustering.service.js`) reads the raw text of every note and groups them into labeled thematic clusters.
At the same time, Voyage AI's voyage-3.5 model (`backend/services/embedding.service.js`) converts each note's text into a numeric representation of its semantic meaning aka vector.
Once the LLM returns, kongruity scores that grouping (`backend/services/validation.service.js`) using an established silhouette coefficient, with cosine distance rather than Euclidean as the proximity metric.
For each note, it weighs the average distance to the other notes in its own cluster against the average distance to the notes in the nearest neighboring cluster. Averaged across every note, this yields a single numeric cohesion score, displayed at the top of the results, along with plaintext: Strong, Moderate, Weak, Poor.
This yields an empirical groundedness evaluation. One model proposes the grouping; an independent model evaluates grouping accuracy.
Note that: before scoring, structural validation confirms that each note landed in exactly one cluster, that no cluster is empty, and that no hallucinated note IDs appear. A malformed response to the validation completely fails, rather than quietly returning a partial board.
## Reading the cohesion score
Average silhouette width is a widely-used measure of clustering quality. Higher values indicate:
1. The qualitative semantic cohesiveness of clusters, and:
2. How well-separated each cluster is from its nearest neighboring cluster.
Although the coefficient is mathematically bounded by [−1, 1], cosine distance between high-dimensional text embeddings is compressed: unrelated notes sit close to orthogonal, so both the within-cluster and nearest-cluster distances land near 0.8. Because silhouette divides the gap between them by the larger of the two, the practical range on embedding data is roughly [−0.05, 0.10] rather than the full interval.
The bands below are therefore calibrated against that observed range. On the seed board, the five ideal thematic clusters score 0.09; swapping a few notes between clusters drops it to 0.06; a scrambled assignment falls below zero.
The score appears above the results with a plain-language band:
- **0.07 and above** — Strong
- **0.04 to 0.06** — Moderate
- **0.01 to 0.03** — Weak
- **Below 0.01** — Poor
A score near 0.00 means the grouping is no better than chance. Bands are specific to `voyage-3` cosine distance and would need recalibration behind a different embedding model. See Hugo Sträng, Tai Dinh. An upper bound on the silhouette evaluation metric for clustering. Pattern Recognition, Volume 178, 2026, 113402, ISSN 0031-3203.
## Organizing clusters, exporting to workflow software
Teams can drag-and-rank related task clusters by implementation priority - turning noise into an actionable workflow.
(Integrations with third-party project management, planning and workflow applications are action-items for next major version, see Roadmap, below)
## How it works ## How it works
@@ -16,14 +61,32 @@ An independent RAG embedding-based model scores evaluates quality using cosine s
4. **Validate** — Structural checks confirm every note is assigned to exactly one cluster, no clusters are empty, and labels are present. 4. **Validate** — Structural checks confirm every note is assigned to exactly one cluster, no clusters are empty, and labels are present.
5. **Prioritize** — Clusters appear ranked and are drag-reorderable. Teams set implementation priority by dragging clusters into position. 5. **Prioritize** — Clusters appear ranked and are drag-reorderable. Teams set implementation priority by dragging clusters into position.
## Dev implementation notes ## Dev implementation notes
As of 03.04.2026, two of the above-described features are in the planning and implementation phase: Developers may swap in other LLM SDKs/APIs and alter prompt syntax in `backend/services/clustering.service.js` to experiment with LLMs and platforms of their choice.
1. **Ingestion** - The implementation goal is a system for easily tagging items/issues mentioned in Slack, Jira comments, etc. (similar to hashtagging), and running batch "pulls" of these tagged items into kongruity via REST API interface. ## Development Roadmap
2. **Prioritization** -- The final prioritization and planning stage will ultimately result in "pushing" these ordered items back **out** into Jira, Asana, Rally (etc.), for incorporation into Epic/Sprint workflows.
Also, developers may swap in other LLM SDKs/APIs and alter prompt syntax in `backend/services/clustering.service.js` to experiment with any model or platform of their choice. ### Ingestion — pulling tagged artifacts in
- [ ] **Slack** — where decisions actually get made; a `:sticky:` emoji reaction fires an Events API webhook that pulls the message in.
- [ ] **Microsoft Teams** — same capture gesture for enterprise shops; message extension plus Graph change notifications.
- [ ] **Jira** — label- or mention-triggered webhook scoped by JQL. (This is where comments typically carry half the backlog's context.)
- [ ] **Linear** — engineering-side tickets and threads; label-triggered GraphQL webhook.
- [ ] **GitHub** — issue, PR review, and discussion comments; label- or mention-triggered webhook.
- [ ] **Miro / FigJam** — REST API import
- [ ] **Confluence / Notion** — page and inline-comment fetch. (Where retro and planning notes are born).
- [ ] **Meeting transcripts (Granola, Otter, Zoom, Google Meet)** — where retros are now recorded, an option for action-item extraction from the transcript API.
- [ ] **Generic REST, email, and Zapier** — authenticated bulk `POST /v1/notes`.
### Export — pushing ranked clusters to workflow tools
- [ ] **Jira** — drag-rank written through the Agile API's board rank endpoint.
- [ ] **Asana** — drag-rank written as task order within the section.
- [ ] **Rally** — drag-rank written as portfolio rank. (Clusters become features and notes, which become stories).
- [ ] **Linear** — drag-rank written to issue `sortOrder`.
- [ ] **Azure DevOps / GitHub Projects v2** — drag-rank written as project field ordering. Clusters become work-item parents.
- [ ] **CSV, JSON, and Markdown** — direct download from the cluster view. (Should ship before any OAuth work.)
## Prerequisites ## Prerequisites
@@ -157,5 +220,3 @@ This runs Vitest with jsdom. For watch mode during development:
```bash ```bash
npm run test:watch npm run test:watch
``` ```

BIN
Voyage.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

42
agents.md Normal file
View File

@@ -0,0 +1,42 @@
# AI Agent Instructions: Fullstack Vite 7 (React) + Express + TypeScript + npm
You are an expert AI fullstack software engineer specialized in Vite 7, React, Express, TypeScript, and modern web architectures. Follow these rules strictly when modifying this codebase.
## 1. Project Structure & Context
* **Frontend:** React SPA powered by Vite 7.x (Entry: `src/main.tsx` or client folder).
* **Backend:** Express Node.js application (Server entry: `server.ts` or server folder).
* **Package Manager:** npm (`package-lock.json` is the strict source of truth).
* **TypeScript Setup:** Strict Mode enabled independently across both environments.
## 2. Express Backend TypeScript Rules
* **Typed Request/Response:** Explicitly type Express route handlers using native Express types:
```typescript
import { Request, Response, NextFunction } from 'express';
// Example for typed request bodies/params:
interface CreateUserBody { username: string; }
app.post('/user', (req: Request<{}, {}, CreateUserBody>, res: Response) => { ... });
```
* **Async Error Catching:** Always wrap async middleware/route handlers in `try/catch` and pass errors to `next(err)`. Do not let unhandled promise rejections crash the Node process.
* **Shared Types:** If frontend and backend share types (e.g., API payloads, User models), place them in a shared directory or export them cleanly from the backend to prevent duplicating code.
## 3. Frontend React + Vite Rules
* **Component Typings:** Use standard type inference or explicit return types (`function Component(): React.JSX.Element`). Avoid the legacy `React.FC`.
* **Strict Prop Types:** Every component must have an explicitly typed `interface` or `type` for its props. No implicit `any`.
* **Event Handlers:** Use exact React synthetic event types (e.g., `React.ChangeEvent<HTMLInputElement>`) instead of generic native events.
* **File Extensions:** Use `.tsx` exclusively for files containing JSX. Use `.ts` strictly for pure logic, hooks, or type definitions.
## 4. Strict Code Quality & Native Guards
* **No `any`:** Never use `any`. Use `unknown` for unpredictable runtime data (like Express `req.body` or frontend `fetch` payloads).
* **No Validation Libraries:** Do not install Zod, TypeBox, or Yup. Write explicit, manual type predicate functions (`function isUser(obj: any): obj is User`) to safely validate runtime data incoming to both the server and client.
* **No Enums:** Avoid TypeScript `enum`. Use string-literal unions (`type Status = 'active' | 'pending'`) or `const StatusEnum = { ... } as const`.
## 5. Verification & Workflow Commands
Before declaring a task complete, you must verify both environments compile flawlessly via npm:
* **Install Dependencies:** `npm install`
* **Type-Check Project:** Run the designated workspace or folder type-checking scripts (e.g., `npm run type-check` or `npx tsc --noEmit` across both roots).
* **Build Verification:** Run production build scripts (e.g., `npm run build`) to ensure both Express asset compilation and Vite bundling pass without error.
## 6. How to Respond
* **Verify Types First:** Run type-checking commands automatically after modifying files to capture compilation breaks before presenting the solution.
* **Targeted Diffs:** Provide concise, targeted updates. Do not rewrite whole files if only a few lines change.
* **Self-Correct:** If a build command fails, read the compiler/Vite/Node logs, fix the root cause, and re-test before asking the user for help.

View File

@@ -1,72 +1,68 @@
import { Transform } from 'node:stream'; import { Transform } from 'node:stream';
/** /**
* Groups an object-mode stream into fixed-size arrays. Backpressure on the * Backpressure on readable side is limits how many batches are in flight.
* readable side is what limits how many batches are ever in flight.
* *
* @param {number} size - maximum items per emitted batch * @param {number} size - maximum items per emitted batch
* @returns {Transform} * @returns {Transform}
*/ */
export const batch = (size) => { export const batch = (size) => {
if (!Number.isInteger(size) || size < 1) { if (!Number.isInteger(size) || size < 1) {
throw new TypeError('batch(size) requires a positive integer size'); throw new TypeError('batch(size) requires a positive integer size');
} }
let pending = []; let pending = [];
return new Transform({ return new Transform({
objectMode: true, objectMode: true,
transform(item, _encoding, callback) { transform(item, _encoding, callback) {
pending.push(item); pending.push(item);
if (pending.length < size) { if (pending.length < size) {
callback(); callback();
return; return;
} }
const full = pending; const full = pending;
pending = []; pending = [];
callback(null, full); callback(null, full);
}, },
flush(callback) { flush(callback) {
if (pending.length === 0) { if (pending.length === 0) {
callback(); callback();
return; return;
} }
const remainder = pending; const remainder = pending;
pending = []; pending = [];
callback(null, remainder); callback(null, remainder);
}, },
}); });
}; };
/** /**
* Serializes an object-mode stream into a JSON array, one element at a time,
* so no complete copy of the payload is ever held in memory.
*
* @returns {Transform} * @returns {Transform}
*/ */
export const jsonArray = () => { export const jsonArray = () => {
let wroteFirst = false; let wroteFirst = false;
return new Transform({ return new Transform({
writableObjectMode: true, writableObjectMode: true,
transform(item, _encoding, callback) { transform(item, _encoding, callback) {
let serialized; let serialized;
try { try {
serialized = JSON.stringify(item); serialized = JSON.stringify(item);
} catch (err) { } catch (err) {
callback(err); callback(err);
return; return;
} }
const prefix = wroteFirst ? ',' : '['; const prefix = wroteFirst ? ',' : '[';
wroteFirst = true; wroteFirst = true;
callback(null, prefix + serialized); callback(null, prefix + serialized);
}, },
flush(callback) { flush(callback) {
callback(null, wroteFirst ? ']' : '[]'); callback(null, wroteFirst ? ']' : '[]');
}, },
}); });
}; };

View File

@@ -27,7 +27,7 @@ export const embedNotes = async (notes) => {
for await (const chunk of batches) { for await (const chunk of batches) {
const response = await client.embed({ const response = await client.embed({
input: chunk.map((n) => n.text), input: chunk.map((n) => n.text),
model: "voyage-3", model: "voyage-3.5",
}); });
response.data.forEach((item, i) => { response.data.forEach((item, i) => {

View File

@@ -1,40 +1,39 @@
{ {
"name": "congruity-frontend", "name": "kongruity-frontend",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest run --config vitest.config.ts", "test": "vitest run --config vitest.config.ts",
"test:watch": "vitest --config vitest.config.ts" "test:watch": "vitest --config vitest.config.ts"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-router-dom": "^7.13.0" "react-router-dom": "^7.13.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24", "eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0", "globals": "^16.5.0",
"jsdom": "^28.0.0", "jsdom": "^28.0.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.48.0", "typescript-eslint": "^8.48.0",
"vite": "^7.3.1", "vite": "^7.3.1",
"vitest": "^4.0.18" "vitest": "^4.0.18"
} }
} }

View File

@@ -6,132 +6,136 @@ import Sticky from './sticky';
import Button from './button'; import Button from './button';
import '../styles/stickies.css'; import '../styles/stickies.css';
// In practice, silhouette on cosine distance between text embeddings occupies roughly
// [-0.05, 0.10], not strict theoretical [-1, 1]: near-orthogonal vectors put both the within- and
// nearest-cluster distances close to 0.8, and the coefficient divides their gap
// by the larger. These bands are calibrated to that range for voyage-3. see README, Reading the cohesion score
const scoreLabel = (score: number): string => { const scoreLabel = (score: number): string => {
if (score >= 0.7) return 'Strong'; if (score >= 0.07) return 'Strong';
if (score >= 0.4) return 'Moderate'; if (score >= 0.04) return 'Moderate';
if (score >= 0.1) return 'Weak'; if (score >= 0.01) return 'Weak';
return 'Poor'; return 'Poor';
}; };
const Stickies = () => { const Stickies = () => {
const { data: stickies, isLoading, error } = useGetStickies(); const { data: stickies, isLoading, error } = useGetStickies();
const { mutate: cluster, data: clusterResponse, isPending } = useClusterStickies(); const { mutate: cluster, data: clusterResponse, isPending } = useClusterStickies();
const [rankedClusters, setRankedClusters] = useState<RankedCluster[]>([]); const [rankedClusters, setRankedClusters] = useState<RankedCluster[]>([]);
const dragIndex = useRef<number | null>(null); const dragIndex = useRef<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null); const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
useEffect(() => { useEffect(() => {
if (clusterResponse?.clusters) { if (clusterResponse?.clusters) {
setRankedClusters( setRankedClusters(
clusterResponse.clusters.map((c, i) => ({ ...c, rank: i + 1 })) clusterResponse.clusters.map((c, i) => ({ ...c, rank: i + 1 }))
); );
} }
}, [clusterResponse]); }, [clusterResponse]);
if (isLoading) return <div>Loading...</div>; if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>; if (error) return <div>Error: {error.message}</div>;
const score = clusterResponse?.score; const score = clusterResponse?.score;
const handleCluster = () => { const handleCluster = () => {
cluster(); cluster();
}; };
const buildStickyMap = (): Map<string, StickyType> => { const buildStickyMap = (): Map<string, StickyType> => {
const map = new Map<string, StickyType>(); const map = new Map<string, StickyType>();
stickies?.forEach((s) => map.set(s.id, s)); stickies?.forEach((s) => map.set(s.id, s));
return map; return map;
}; };
const stickyMap = buildStickyMap(); const stickyMap = buildStickyMap();
const renderStickies = (items: StickyType[]) => const renderStickies = (items: StickyType[]) =>
items?.map((sticky) => <Sticky key={sticky.id} sticky={sticky} />); items?.map((sticky) => <Sticky key={sticky.id} sticky={sticky} />);
const handleDragStart = (index: number) => { const handleDragStart = (index: number) => {
dragIndex.current = index; dragIndex.current = index;
}; };
const handleDragOver = (e: DragEvent, index: number) => { const handleDragOver = (e: DragEvent, index: number) => {
e.preventDefault(); e.preventDefault();
setDragOverIndex(index); setDragOverIndex(index);
}; };
const handleDragLeave = () => { const handleDragLeave = () => {
setDragOverIndex(null); setDragOverIndex(null);
}; };
const handleDrop = (targetIndex: number) => { const handleDrop = (targetIndex: number) => {
const sourceIndex = dragIndex.current; const sourceIndex = dragIndex.current;
if (sourceIndex === null || sourceIndex === targetIndex) { if (sourceIndex === null || sourceIndex === targetIndex) {
dragIndex.current = null; dragIndex.current = null;
setDragOverIndex(null); setDragOverIndex(null);
return; return;
} }
const reordered = [...rankedClusters]; const reordered = [...rankedClusters];
const [moved] = reordered.splice(sourceIndex, 1); const [moved] = reordered.splice(sourceIndex, 1);
reordered.splice(targetIndex, 0, moved); reordered.splice(targetIndex, 0, moved);
setRankedClusters(reordered.map((c, i) => ({ ...c, rank: i + 1 }))); setRankedClusters(reordered.map((c, i) => ({ ...c, rank: i + 1 })));
dragIndex.current = null; dragIndex.current = null;
setDragOverIndex(null); setDragOverIndex(null);
}; };
const handleDragEnd = () => { const handleDragEnd = () => {
dragIndex.current = null; dragIndex.current = null;
setDragOverIndex(null); setDragOverIndex(null);
}; };
console.log(score?.toFixed(2))
return ( return (
<div className="stickies-container"> <div className="stickies-container">
<Button onClick={handleCluster} isLoading={isPending} label="Group Stickies By Topic" /> <Button onClick={handleCluster} isLoading={isPending} label="Group Stickies By Topic" />
{rankedClusters.length > 0 ? ( {rankedClusters.length > 0 ? (
<div className="clusters-container"> <div className="clusters-container">
{score != null && ( {score != null && (
<div className="cohesion-score"> <div className="cohesion-score">
Cluster cohesion: <strong>{score.toFixed(2)}</strong> — {scoreLabel(score)} Cluster cohesion: <strong>{scoreLabel(score)}</strong>
</div> </div>
)} )}
{rankedClusters.map((group, index) => ( {rankedClusters.map((group, index) => (
<div <div
key={group.label} key={group.label}
className={`cluster-group cluster-draggable${dragOverIndex === index ? ' cluster-drag-over' : ''}`} className={`cluster-group cluster-draggable${dragOverIndex === index ? ' cluster-drag-over' : ''}`}
draggable draggable
onDragStart={() => handleDragStart(index)} onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)} onDragOver={(e) => handleDragOver(e, index)}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={() => handleDrop(index)} onDrop={() => handleDrop(index)}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
<div className="cluster-header"> <div className="cluster-header">
<span className="cluster-rank" aria-label={`Priority ${group.rank}`}> <span className="cluster-rank" aria-label={`Priority ${group.rank}`}>
{group.rank} {group.rank}
</span> </span>
{group.rank === 1 && ( {group.rank === 1 && (
<span className="cluster-reorder-hint">Drag and drop to reorganize cluster priority</span> <span className="cluster-reorder-hint">Drag and drop to reorganize cluster priority</span>
)} )}
<h3 className="cluster-label">{group.label}</h3> <h3 className="cluster-label">{group.label}</h3>
<span className="cluster-drag-handle" aria-hidden="true">⠿</span> <span className="cluster-drag-handle" aria-hidden="true">⠿</span>
</div> </div>
<div className="stickies-grid"> <div className="stickies-grid">
{renderStickies( {renderStickies(
group?.noteIds group?.noteIds
.map((id) => stickyMap?.get(id)) .map((id) => stickyMap?.get(id))
.filter((s): s is StickyType => !!s) .filter((s): s is StickyType => !!s)
)} )}
</div> </div>
</div> </div>
))} ))}
</div>
) : (
<div className="stickies-grid">
{renderStickies(stickies ?? [])}
</div>
)}
</div> </div>
) : ( );
<div className="stickies-grid">
{renderStickies(stickies ?? [])}
</div>
)}
</div>
);
}; };
export default Stickies; export default Stickies;

View File

@@ -1,100 +1,107 @@
.stickies-grid { .stickies-grid {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 16px; gap: 16px;
justify-content: center; justify-content: center;
margin-top: 18px; margin-top: 18px;
padding: 24px; padding: 24px;
} }
.stickies-container { .stickies-container {
margin: 36px 0px 36px 0px; margin: 36px 0px 36px 0px;
} }
.clusters-container { .clusters-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 32px; gap: 32px;
padding: 24px 0; padding: 24px 0;
} }
.cluster-group { .cluster-group {
border: 1px solid #6dd6f4; border: 1px solid #6dd6f4;
border-radius: 8px; border-radius: 8px;
padding: 16px; padding: 16px;
} }
.cluster-draggable { .cluster-draggable {
cursor: grab; cursor: grab;
transition: box-shadow 0.2s ease, border-color 0.2s ease, transform 0.15s ease; transition: box-shadow 0.2s ease, border-color 0.2s ease, transform 0.15s ease;
} }
.cluster-draggable:active { .cluster-draggable:active {
cursor: grabbing; cursor: grabbing;
} }
.cluster-drag-over { .cluster-drag-over {
border-color: #ffb7ce; border-color: #ffb7ce;
box-shadow: 0 0 12px rgba(255, 183, 206, 0.4); box-shadow: 0 0 12px rgba(255, 183, 206, 0.4);
transform: scale(1.01); transform: scale(1.01);
} }
.cluster-header { .cluster-header {
display: flex; position: relative;
align-items: center; display: flex;
gap: 12px; align-items: center;
margin-bottom: 16px; gap: 12px;
margin-bottom: 16px;
min-height: 32px;
} }
.cluster-rank { .cluster-rank {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 32px; width: 32px;
height: 32px; height: 32px;
border-radius: 50%; border-radius: 50%;
background: #6dd6f4; background: #6dd6f4;
color: #1a1a2e; color: #1a1a2e;
font-weight: 700; font-weight: 700;
font-size: 0.95em; font-size: 0.95em;
flex-shrink: 0; flex-shrink: 0;
} }
.cluster-reorder-hint { .cluster-reorder-hint {
font-size: 0.8em; font-size: 0.8em;
color: #9ca3af; color: #9ca3af;
font-style: italic; font-style: italic;
white-space: nowrap; white-space: nowrap;
flex-shrink: 0; flex-shrink: 0;
} }
.cluster-label { .cluster-label {
margin: 0; position: absolute;
font-size: 1.2em; left: 50%;
font-weight: 600; transform: translateX(-50%);
flex: 1; max-width: 50%;
margin: 0;
font-size: 1.2em;
font-weight: 600;
pointer-events: none;
} }
.cluster-drag-handle { .cluster-drag-handle {
font-size: 1.4em; margin-left: auto;
color: #6dd6f4; font-size: 1.4em;
opacity: 0.4; color: #6dd6f4;
user-select: none; opacity: 0.4;
transition: opacity 0.2s ease; user-select: none;
flex-shrink: 0; transition: opacity 0.2s ease;
flex-shrink: 0;
} }
.cluster-draggable:hover .cluster-drag-handle { .cluster-draggable:hover .cluster-drag-handle {
opacity: 0.8; opacity: 0.8;
} }
.cohesion-score { .cohesion-score {
text-align: center; text-align: center;
font-size: 0.95em; font-size: 0.95em;
color: #e0e0e0; color: #e0e0e0;
padding: 8px 16px; padding: 8px 16px;
background: rgba(109, 214, 244, 0.1); background: rgba(109, 214, 244, 0.1);
border-radius: 6px; border-radius: 6px;
width: fit-content; width: fit-content;
margin: 0 auto; margin: 0 auto;
} }

View File

@@ -14,7 +14,7 @@ const MOCK_CLUSTER_RESPONSE = {
{ label: 'Auth Issues', noteIds: ['note_001'] }, { label: 'Auth Issues', noteIds: ['note_001'] },
{ label: 'Export Issues', noteIds: ['note_002'] }, { label: 'Export Issues', noteIds: ['note_002'] },
], ],
score: 0.74, score: 0.09,
}; };
let fetchMock: ReturnType<typeof vi.fn>; let fetchMock: ReturnType<typeof vi.fn>;