You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
✅ Already event-sourced (all go through dispatch())
Event Type
Triggered By
CriterionAdded
addCriterion()
CriterionRenamed
renameCriterion() / saveHeaderEdit()
CriterionRemoved
removeCriterion()
ToolAdded
addTool()
ToolRenamed
renameTool() / saveHeaderEdit()
ToolRemoved
removeTool()
ScoreSet
addScore()
WeightSet
setWeight()
Note: Criteria and option lifecycle events are already event-sourced as of the current codebase. Every mutation goes through dispatch(makeEvent(...)).
Branching architecture findings
IDs:branch-${Date.now()}-${Math.random().toString(36).slice(2, 8)} (timestamp + 6-char random). main branch hardcoded as id: 'main'.
Event IDs: Same pattern — evt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.
Fork model:createBranchcopies all parent events into new branch ([...parentEvents]). parentBranchId and forkEventIndex are stored but never used for reconstruction. Each branch is fully self-contained — no parent chain traversal.
Persistence: Only eventsByBranch, branches, and activeBranchId are persisted (via Zustand partialize). Domain state is re-derived on rehydrate.
Issues to file
#
Title
Priority
Notes
1
Branch architecture: ID generation, parent chain, merge
Branch Architecture: Git-Inspired Event Store with Pluggable Storage
Vision
Model the matrix event store after git's core data structures — content-addressable objects, commits, and refs — but keep the abstraction storage-agnostic so it works equally well in localStorage, Supabase, Convex, or any future backend.
Git Concepts → Matrix Concepts
Git
Decision App
Notes
Blob
PughEvent
Atomic unit of change (ScoreSet, CriterionAdded, etc.)
Tree
Snapshot
Materialized state at a point in time (criteria, tools, scores, weights)
Commit
Commit
Points to a snapshot + parent commit(s) + metadata
Ref / Branch
BranchRef
Named pointer to a commit hash
HEAD
activeBranchId
Which branch is currently checked out
SHA-1
Content hash
Deterministic ID derived from content, not timestamp+random
Proposed Data Model
Events (blobs)
interfacePughEvent{id: string;// content-addressable hash of the event payloadtype: PughEventType;timestamp: number;user: string;// ... type-specific fields}
Event IDs should be deterministic — hash of (type, timestamp, user, payload). This makes deduplication trivial across replicas.
Commits
interfaceCommit{id: string;// hash of (parentIds + eventIds + timestamp + author)parentIds: string[];// 1 parent = normal commit, 2 = merge commit, 0 = rooteventIds: string[];// ordered list of event hashes in this commitauthor: string;timestamp: number;message?: string;// optional, like git commit messages}
A commit bundles one or more events together. You can commit after every keystroke (auto-save) or batch changes into logical units.
Refs (branches + tags)
interfaceRef{name: string;// 'main', 'alice/experiment', etc.commitId: string;// points to latest commit on this branchtype: 'branch'|'tag';// tags are immutable refs}
State reconstruction
To get the current state for a branch:
1. Resolve ref → commitId
2. Walk commit chain via parentIds (like git log)
3. Collect all eventIds in topological order
4. projectEvents(events) → domain state
This naturally deduplicates shared history — forked branches share parent commits, not copied events.
Merge
// A merge commit has two parentsconstmergeCommit: Commit={id: hash(...),parentIds: [mainHeadId,featureBranchHeadId],eventIds: resolutionEventIds,// conflict resolution events, if anyauthor: 'cedric',timestamp: Date.now(),message: 'Merge experiment into main',};
Conflict detection: walk both branches from common ancestor, find cells with divergent ScoreSet events. Resolution events in the merge commit settle conflicts.
Storage Abstraction
The key insight: separate the object store from the ref store. Both are simple key-value operations.
[REFACTOR] Adopt git-inspired branching with pluggable storage
Problem
Current branching copies all parent events into each new branch ([...parentEvents]). parentBranchId and forkEventIndex are stored but never used for reconstruction. IDs are weak (timestamp + 6 random chars). No merge capability.
Git Concepts → Matrix Concepts
Git
Decision App
Notes
Blob
PughEvent
Atomic unit of change
Commit
Commit
Bundle of events + parent pointer(s) + metadata
Ref
BranchRef
Named pointer to a commit
HEAD
activeBranchId
Current branch
SHA
UUIDv7
Sortable, timestamp-embedded
Tree
Projected state
Materialized by replaying events
Data Model
Commits
interfaceCommit{id: string;// UUIDv7parentIds: string[];// 0 = root, 1 = normal, 2 = mergeeventIds: string[];// ordered UUIDv7s of events in this commitauthor: string;timestamp: number;comment?: string;}
All scores are currently hardcoded to a 1–10 integer scale. The color palette (SCORE_COLORS_LIGHT / SCORE_COLORS_DARK) is a fixed 10-entry lookup table. Different criteria benefit from different scales — e.g., cost might be 1-5 stars, satisfaction a thumbs up/down, or impact a -5 to +5 range.
Criteria and options only have id, label, and user. There's no way to explain what a criterion measures or provide context about an option. For real-world evaluations, descriptions are essential — "Performance" could mean response time, throughput, or benchmark scores depending on context.
[FEAT] Support configurable score scales per criterion
Problem
All scores are hardcoded to 1–10 integers. Different criteria call for different scales — star ratings, unbounded counts, binary yes/no, or arbitrary numeric ranges. The current SCORE_COLORS_LIGHT/SCORE_COLORS_DARK lookup tables assume exactly 10 discrete values.
Design
Matrix-level default + per-criterion override
Each matrix has a default scale (set via MatrixDefaultScaleSet event). Individual criteria can override it (via CriterionScaleOverridden event). If neither is set, the default is { kind: 'numeric', min: 1, max: 10, step: 1 }.
Scale types
typeScaleType=|{kind: 'numeric';min: number;max: number;step: number}// e.g., 1-10, 0-5, -5 to +5|{kind: 'binary'}// yes/no, pass/fail|{kind: 'unbounded'}// github stars, revenue, etc.
No labels on scales. Scores are purely numeric. The current codebase requires a text label with every score — this is removed. If users want qualitative context, they use:
Criterion descriptions (markdown) to explain what the scale means
Cell comments to annotate individual scores
If there's future demand, a categorical scale type could map discrete labels to values (e.g., "Poor/Fair/Good/Excellent" → 1/2/3/4), but that's out of scope here.
The CellScored event has a required score: number and optional comment?: string. No label field.
No mixed sign within a matrix
A matrix must be either all unsigned (min ≥ 0) or all signed (min < 0). This simplifies normalization and color mapping. Set at the matrix level:
interfaceMatrixCreatedextendsPughEventBase{type: 'MatrixCreated';title: string;description?: string;allowNegative: boolean;// false = all scales must have min ≥ 0defaultScale: ScaleType;}
Validation: if allowNegative: false, reject any scale with min < 0.
Decimals
The step field controls precision:
step: 1 → integers only (1, 2, 3...)
step: 0.5 → half-steps (4.0, 4.5, 5.0...)
step: 0.1 → tenths
Input validation rounds to the nearest valid step.
Normalization for weighted totals
All scores must be normalized to [0, 1] (or [-1, 1] for signed matrices) before weighting. This makes totals comparable across different scale types.
Unbounded values (GitHub stars, revenue) have no fixed range. Normalization is relative to other options in the same criterion:
// Each cell's share of the totalnormalized=cellValue/sum(allValuesForThisCriterion)
If all values are 0, normalized = 0. This naturally produces a 0–1 range where the proportions are preserved.
Edge case: If one option has 50,000 GitHub stars and others have 200, the dominant option gets ~0.99. This is by design — it reflects the actual ratio. If users want less extreme weighting, they can adjust the criterion weight or use a bounded scale with a cap.
Color mapping
Replace the fixed 10-entry lookup with a continuous function:
functiongetScoreColor(score: number,scale: ScaleType,allScores: number[],isDark: boolean){constratio=normalizeScore(score,scale,allScores);// 0.0 to 1.0conststep=Math.round(ratio*9)+1;// map to 1-10 palette indexreturn(isDark ? SCORE_COLORS_DARK : SCORE_COLORS_LIGHT)[step];}
For signed matrices, map [-1, 1] → [1, 10] (negative = red, zero = yellow, positive = green).
Input components
Scale
Input
numeric (bounded)
Number input with min/max/step constraints
binary
Toggle switch or checkbox
unbounded
Number input, no bounds, integers only
Acceptance Criteria
ScaleType union type defined
MatrixDefaultScaleSet event for matrix-level default
CriterionScaleOverridden event for per-criterion override
allowNegative flag on matrix prevents mixed-sign scales
step field controls decimal precision
Normalization function handles bounded, unbounded, and signed scales
getScoreColor adapts to normalized values
Weighted totals use normalized scores
Input components per scale type (number, toggle)
Storybook stories for each scale type
Backward compatible — omitted scale defaults to numeric 1-10
Score cells show history and comments via Radix HoverCard on hover. This doesn't work on mobile (no hover) and provides limited space for rich content like threaded comments or long history.
Note: Editable matrices are desktop-only. Mobile will be read-only.
Design
Replace HoverCard with a Radix Sheet (drawer from the right) on click:
Drawer contents
Cell identity — option × criterion label
Current score — editable (desktop), read-only (mobile)
Score history — all CellScored events for this cell, with author + timestamp
Threaded comments — via CellCommented events with parentCommentId
New event type
interfaceCellCommentedextendsPughEventBase{type: 'CellCommented';optionId: string;criterionId: string;comment: string;parentCommentId?: string;// for threading}
Vitest is configured with browser-playwright and coverage-v8, but there are minimal actual test files. The event sourcing logic, projection reducer, and store actions have no dedicated unit tests. Storybook interaction tests provide some coverage but aren't a substitute for targeted unit tests.
Current Test Infrastructure
vitest.config.ts configured with browser mode (playwright)
@vitest/coverage-v8 installed
.storybook/vitest.setup.ts exists
Storybook stories have some play functions for interaction testing
Proposed Test Coverage
Priority 1: Pure logic (no React)
These are fast, isolated, high-value:
projectEvents() — the core reducer
Empty events → empty state
CriterionAdded / ToolAdded populates arrays
CriterionRenamed / ToolRenamed updates labels
CriterionRemoved / ToolRemoved cascades (removes related scores)
ScoreSet appends to scores array
WeightSet updates weights map
Event ordering matters (later events override)
seedEventsFromOptions()
Generates correct event types from legacy data
Skips default weight (10) events
Preserves user attribution
makeEvent() (currently inline in createPughStore)
Generates unique IDs
Sets timestamp and user
Priority 2: Store behavior
createPughStore() — integration tests
Initial state from options
dispatch updates domain state
Branch CRUD (create, switch, rename, delete)
Branch isolation (events on one branch don't affect another)
Cannot delete main branch
Persistence round-trip (save → load → same state)
Priority 3: Component rendering
PughMatrix — via React Testing Library or Storybook tests
The app currently persists to localStorage only. The Persister interface is already abstracted, making this a natural extension point. For the "GitHub for Pugh matrices" vision, multiple users need to collaborate on the same matrix in real time.
User makes a change → dispatch() fires → event appended locally
Persister writes event to Supabase
Supabase Realtime broadcasts to other clients
Other clients receive event → append to local store → re-project
Considerations
Conflict resolution: With event sourcing, conflicts = same cell scored by different users simultaneously. Last-write-wins by timestamp, or present merge UI.
Optimistic updates: Apply locally first, sync async. Revert on failure.
Offline support: Queue events in localStorage, flush when reconnected.
Auth: Supabase Auth for user identity. user field on events maps to auth user.
[FEAT] Add Supabase persistence and real-time sync
Context
This would ship as a separate package (@decisioncc/storage-supabase) — the core @decisioncc/react-pugh-matrix component has no Supabase dependency. The core ships with memory (default, no persistence) and localStorage adapters.
Eventual plans include a @decisioncc/storage-convex adapter, but that's lower priority.
What Supabase Enables
1. Persistent storage
Events and commits stored in Postgres. Matrices survive across devices and sessions.
2. User authentication
Supabase Auth provides user identity. Events are attributed to authenticated users (not 'anonymous'). Enables:
Per-user score attribution
Access control (viewer/editor/admin per matrix)
Audit trail of who changed what
3. Real-time collaboration
Supabase Realtime broadcasts changes. When one user scores a cell, all connected clients see the update immediately. Enables:
Uses the ObjectStore + RefStore interfaces from the branching refactor:
createtablematrices (
id uuid primary key default gen_random_uuid(),
slug text unique,
owner_id uuid referencesauth.users,
allow_negative boolean default false,
default_scale jsonb default '{"kind":"numeric","min":1,"max":10,"step":1}',
created_at timestamptz default now()
);
createtablematrix_objects (
id textprimary key, -- UUIDv7
matrix_id uuid references matrices,
kind textnot null, -- 'event' | 'commit'
branch_id textnot null, -- partition events by branch
data jsonb not null,
created_at timestamptz default now()
);
createtablematrix_refs (
matrix_id uuid references matrices,
name textnot null,
commit_id textreferences matrix_objects(id),
type text default 'branch',
primary key (matrix_id, name)
);
createtablematrix_collaborators (
matrix_id uuid references matrices,
user_id uuid referencesauth.users,
role textcheck (role in ('viewer', 'editor', 'admin')),
primary key (matrix_id, user_id)
);
Real-Time Flow
User dispatches event → committed locally (optimistic)
Supabase persister writes to matrix_objects
Supabase Realtime broadcasts insert to other clients
Other clients append event → re-project domain state
Conflict handling
With event sourcing, conflicts = same cell scored by different users at ~the same time. Resolution:
Default: Last-write-wins by UUIDv7 ordering (timestamp-embedded)
Future: Merge UI showing conflicting scores for manual resolution
Offline support
Queue events in memory/localStorage → flush to Supabase on reconnect. The ObjectStore interface supports this naturally — local writes succeed immediately, sync is async.
Acceptance Criteria
@decisioncc/storage-supabase package with ObjectStore + RefStore implementations
Supabase Auth integration — events attributed to authenticated users
Real-time sync via Supabase Realtime subscriptions
Past tense — events describe something that happened, not a command
Domain language — use the domain terms (Criterion, Option, Score, Weight), not generic CRUD (Created, Updated, Deleted)
Specific over generic — CriterionWeightAdjusted not FieldUpdated
Aggregate prefix — group by the aggregate/entity they affect: Matrix.*, Criterion.*, Option.*, Score.*
No abbreviations — DescriptionChanged not DescChanged
Naming pattern: {Entity}{WhatHappened}
Current Events (8)
Event
Entity
Status
Notes
CriterionAdded
Criterion
✅ Good
CriterionRenamed
Criterion
✅ Good
CriterionRemoved
Criterion
✅ Good
ToolAdded
Tool
⚠️ Rename
Should be OptionAdded — "Tool" is domain-specific to the demo data. The generic concept is "Option"
ToolRenamed
Tool
⚠️ Rename
→ OptionRenamed
ToolRemoved
Tool
⚠️ Rename
→ OptionRemoved
ScoreSet
Score
⚠️ Rename
"Set" is imperative. → ScoreRecorded or CellScored
WeightSet
Criterion
⚠️ Rename
→ CriterionWeightAdjusted
Renaming rationale: Tool → Option
The current codebase uses "Tool" because the demo compares React/Vue/Svelte. But a Pugh matrix compares options (alternatives, candidates). "Tool" leaks a specific use case into the generic data model. The component props, types, and events should use Option as the universal term.
This is a breaking change to the event schema — handle via versioned event migration (see Migration section below).
The giant switch over 8 event types with nested logic. Will improve significantly with the event type refactor (#6) — recommend splitting into per-event-type handler functions:
[REFACTOR] Split readOnly into granular display and interaction props
Problem
The current readOnly boolean is too coarse. Two distinct read-only modes were discussed:
Weights locked, ratings editable — useful when a facilitator sets weights and participants only rate
Everything locked — for embedding in docs, presentations, read-only sharing
Additionally, showTotals is currently internal toggle state, but it should also be controllable as a prop. Showing totals on a matrix with hidden weights implies equal weighting, which could mislead viewers into thinking one option is categorically better.
Current Props
interfacePughMatrixProps{highlight?: string;showWinner?: boolean;isDark?: boolean;readOnly?: boolean;// single boolean for everything}
Internal state: showTotals, showWeights (toggled via buttons).
Proposed Props
interfacePughMatrixProps{// Displayhighlight?: string;showWinner?: boolean;isDark?: boolean;// Visibility (can also be toggled via UI buttons when not locked)showWeights?: boolean;// show/hide weight columnshowTotals?: boolean;// show/hide weighted totals row// Interaction locksreadOnlyWeights?: boolean;// weights visible but not editablereadOnlyRatings?: boolean;// ratings visible but not editable// ConveniencereadOnly?: boolean;// shorthand: sets both readOnlyWeights + readOnlyRatings}
Prop precedence
readOnly={true} sets both readOnlyWeights and readOnlyRatings to true
Specific flags override: readOnly={true} readOnlyRatings={false} → weights locked, ratings editable (unusual but valid)
showWeights and showTotals as props override the initial internal state but still allow toggling (unless a locked variant is needed)
Use cases
Scenario
Props
Facilitator sets weights, team rates
readOnlyWeights
Embedded in Docusaurus
readOnly
Presentation mode (no totals)
readOnly showTotals={false}
Collaborative rating, no weights shown
showWeights={false} showTotals={false}
Full interactive (default)
(no props needed)
Totals without weights = misleading
If showWeights={false} and showTotals={true}, the totals assume equal weighting. This should either:
Show a disclaimer ("assuming equal weights")
Or require showWeights={true} when showTotals={true} (enforced via prop validation / console warning)
Acceptance Criteria
readOnlyWeights and readOnlyRatings as separate boolean props
readOnly as shorthand for both
showWeights and showTotals controllable as props
Prop validation: warn when totals shown without weights
UI buttons (Hide Weights / Hide Totals) still work when props don't lock them
Add/remove rows+cols hidden when readOnlyRatings is true
Storybook stories for each combination
Update feat/radix-table branch if it diverges from these props