Compare commits
8 Commits
edeadd547a
...
update-age
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22667b38b8 | ||
| e547dee979 | |||
|
|
362a47f88a | ||
| 274e846909 | |||
|
|
c6b07ccb56 | ||
|
|
8e79e78006 | ||
| 19d4a82c90 | |||
|
|
6f09b6ecdc |
20
README.md
20
README.md
@@ -8,11 +8,15 @@ In kongruity, the artifacts become "sticky notes." A board full of them looks ch
|
|||||||
|
|
||||||
With a click, they are semantically evaluated, grouped into thematic clusters with descriptive headers, rankable and exportable to project planning and execution tools.
|
With a click, they are semantically evaluated, grouped into thematic clusters with descriptive headers, rankable and exportable to project planning and execution tools.
|
||||||
|
|
||||||
|
## Voyage AI voyage-3.5
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## Clustering and evaluation: methodology
|
## 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.
|
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 model (`backend/services/embedding.service.js`) converts each note's text into a numeric representation of its semantic meaning aka vector.
|
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.
|
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.
|
||||||
|
|
||||||
@@ -30,14 +34,18 @@ Average silhouette width is a widely-used measure of clustering quality. Higher
|
|||||||
|
|
||||||
2. How well-separated each cluster is from its nearest neighboring cluster.
|
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:
|
The score appears above the results with a plain-language band:
|
||||||
|
|
||||||
- **0.70 and above** — Strong
|
- **0.07 and above** — Strong
|
||||||
- **0.40 to 0.69** — Moderate
|
- **0.04 to 0.06** — Moderate
|
||||||
- **0.10 to 0.39** — Weak
|
- **0.01 to 0.03** — Weak
|
||||||
- **Below 0.10** — Poor
|
- **Below 0.01** — Poor
|
||||||
|
|
||||||
Silhouette values are archetypically bounded below 1.0 for real-world data, so the number is best read as a relative measure. 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.
|
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
|
## Organizing clusters, exporting to workflow software
|
||||||
|
|
||||||
|
|||||||
BIN
Voyage.jpg
Normal file
BIN
Voyage.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
42
agents.md
Normal file
42
agents.md
Normal 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.
|
||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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>;
|
||||||
|
|||||||
Reference in New Issue
Block a user