Files
product-radar/DESIGN.md
2026-06-26 14:39:48 +01:00

24 KiB
Raw Permalink Blame History

Product Radar — Design (QFD)

Goal-driven design for Product Radar: a solo, browser-local tool for positioning a product against alternatives on a radar chart. This document translates user-facing goals into measurable functions, the components that realise them, and the trade-offs taken. It relies on CONTEXT.md for the ubiquitous language (Radar, Criterion, Profile, Score, Concept) and does not redefine those terms here.

Strength weights used in matrices: 9 strong, 3 medium, 1 weak, blank none.


House of Quality

% =====================================================================
% QFD "House of Quality" preamble
% =====================================================================
\usetikzlibrary{arrows.meta, positioning, shapes.geometric, shapes.misc, calc, fit, backgrounds}

\newif\ifqfdshowroof          \qfdshowrooftrue
\newif\ifqfdshowbasement      \qfdshowbasementtrue
\newif\ifqfdshowcompetitive   \qfdshowcompetitivetrue
\newif\ifqfdshowlegend        \qfdshowlegendtrue
\newif\ifqfdshowimportance    \qfdshowimportancetrue
\newif\ifqfdshowcorrlegend    \qfdshowcorrlegendtrue
\newif\ifqfdshowevallegend    \qfdshowevallegendtrue
\newif\ifqfdshowtitle         \qfdshowtitletrue

\def\qfdNW{5}
\def\qfdNH{5}
\def\qfdWhatW{4.0}
\def\qfdImpW{0.9}
\def\qfdCmpW{3}
\def\qfdHdrH{2.6}
\def\qfdBasementN{4}

\def\qfdWhatsTitle{Customer needs}
\def\qfdImpTitle{Imp.\ \%}
\def\qfdPerceptionTitle{Comparative evaluation}
\def\qfdPoorLabel{poor}
\def\qfdExcellentLabel{excellent}
\def\qfdAltOneLabel{Our product}
\def\qfdAltTwoLabel{Competitor A}
\def\qfdAltThreeLabel{Competitor B}
\def\qfdRelTitle{Relation}
\def\qfdCorrTitle{Correlation}
\def\qfdEvalTitle{Evaluation}

\def\qfdProjectTitle{}
\def\qfdConcept{}

\tikzset{
  qfdthin/.style ={line width=0.35pt},
  qfdmed/.style  ={line width=0.7pt},
  qfdstrong/.style={circle, draw, fill=black,
                    minimum size=7pt, inner sep=0pt},
  qfdmod/.style  ={circle, draw,
                    minimum size=7pt, inner sep=0pt, line width=0.8pt},
  qfdweak/.style ={regular polygon, regular polygon sides=3, draw,
                    minimum size=8.5pt, inner sep=0pt, line width=0.7pt},
  qfdrel/.is choice,
  qfdrel/S/.style={qfdstrong},
  qfdrel/M/.style={qfdmod},
  qfdrel/W/.style={qfdweak},
  qfdalt1mk/.style={circle, draw, fill=black,
                    minimum size=6pt, inner sep=0pt, line width=1pt},
  qfdalt1ln/.style={line width=1.2pt},
  qfdalt2mk/.style={regular polygon, regular polygon sides=3, draw,
                    fill=black, minimum size=6pt, inner sep=0pt,
                    line width=0.7pt},
  qfdalt2ln/.style={line width=0.7pt, dashed},
  qfdalt3mk/.style={rectangle, draw, fill=black,
                    minimum size=5pt, inner sep=0pt, line width=0.7pt},
  qfdalt3ln/.style={line width=0.7pt, dotted},
}

\newcommand{\qfdDrawGrid}{%
  \foreach \c in {1,...,\qfdNHm} \draw[qfdthin] (\c, 0) -- (\c, -\qfdNW);
  \foreach \r in {1,...,\qfdNWm} \draw[qfdthin] (0, -\r) -- (\qfdNH, -\r);
  \foreach \r in {1,...,\qfdNWm}
    \draw[qfdthin] (\qfdLeftEdge, -\r) -- (0, -\r);
  \ifqfdshowroof
    \foreach \c in {1,...,\qfdNHm}
      \draw[qfdthin] (\c, 0) -- (\c, \qfdHdrH);
  \fi
  \ifqfdshowcompetitive
    \foreach \r in {1,...,\qfdNWm}
      \draw[qfdthin] (\qfdNH, -\r) -- (\qfdNH+\qfdCmpW, -\r);
  \fi
  \ifqfdshowbasement
    \foreach \r in {1,...,\qfdBasementN}
      \draw[qfdthin] (0, -\qfdNW-\r) -- (\qfdNH, -\qfdNW-\r);
    \foreach \c in {1,...,\qfdNHm}
      \draw[qfdthin] (\c, -\qfdNW) -- (\c, -\qfdNW-\qfdBasementN);
  \fi
}

\newcommand{\qfdDrawRoof}{%
  \ifqfdshowroof
    \foreach \k in {1,...,\qfdNHm} {%
      \pgfmathsetmacro{\rx}{(\k+\qfdNH)/2}
      \pgfmathsetmacro{\ry}{\qfdHdrH + (\qfdNH-\k)/2}
      \pgfmathsetmacro{\lx}{\k/2}
      \pgfmathsetmacro{\ly}{\qfdHdrH + \k/2}
      \draw[qfdthin] (\k, \qfdHdrH) -- (\rx, \ry);
      \draw[qfdthin] (\k, \qfdHdrH) -- (\lx, \ly);
    }%
    \draw[qfdmed] (0, \qfdHdrH)
       -- (\qfdNH/2, \qfdApexY) -- (\qfdNH, \qfdHdrH);
    \foreach \i in {1,...,\qfdNH}
      \foreach \k in {1,...,\qfdNH} {%
        \pgfmathtruncatemacro{\jj}{\i+\k}
        \ifnum\jj>\qfdNH\relax\else
          \pgfmathsetmacro{\xx}{\i + \k/2 - 0.5}
          \pgfmathsetmacro{\yy}{\qfdHdrH + \k/2}
          \coordinate (C-\i-\jj) at (\xx, \yy);
        \fi
      }%
  \fi
}

\newcommand{\qfdDrawScale}{%
  \ifqfdshowcompetitive
    \foreach \tk in {0,1,2,3,4,5} {%
      \pgfmathsetmacro{\tx}{\qfdNH + (\tk+0.5)*\qfdCmpW/6}
      \node[anchor=south, font=\scriptsize] at (\tx, 0.02) {\tk};
    }%
    \node[anchor=south, font=\scriptsize\bfseries, align=center]
         at ({\qfdNH + \qfdCmpW/2}, 0.7) {\qfdPerceptionTitle};
    \node[anchor=north, font=\scriptsize\itshape]
         at ({\qfdNH + 0.45}, -\qfdNW) {\qfdPoorLabel};
    \node[anchor=north, font=\scriptsize\itshape]
         at ({\qfdNH + \qfdCmpW - 0.45}, -\qfdNW) {\qfdExcellentLabel};
  \fi
}

\newcommand{\qfdDrawZoneTitles}{%
  \ifqfdshowimportance
    \node[rotate=90, anchor=west, font=\footnotesize\bfseries]
         at ({-\qfdImpW/2}, 0.12) {\qfdImpTitle};
  \fi
  \node[font=\scriptsize\bfseries, align=center, text width=\qfdWhatW cm]
       at ({\qfdLeftEdge + \qfdWhatW/2},
           {\ifqfdshowroof \qfdHdrH/2 \else 0.6 \fi}) {\qfdWhatsTitle};
}

\newcommand{\qfdDrawTitle}{%
  \ifqfdshowtitle
    \ifx\qfdProjectTitle\empty\else
      \pgfmathsetmacro{\qfdTitleX}{\qfdNH/2}
      \pgfmathsetmacro{\qfdTitleY}{\ifqfdshowroof \qfdApexY \else \qfdHdrH \fi + 0.9}
      \pgfmathsetmacro{\qfdSubW}{\qfdNH + 2}
      \node[anchor=south, font=\large\bfseries, align=center]
           at (\qfdTitleX, \qfdTitleY) {\qfdProjectTitle};
      \ifx\qfdConcept\empty\else
        \node[anchor=north, font=\footnotesize\itshape, align=center,
              text width=\qfdSubW cm]
             at (\qfdTitleX, {\qfdTitleY - 0.1}) {\qfdConcept};
      \fi
    \fi
  \fi
}

\newcommand{\qfdDrawFrames}{%
  \begin{scope}[qfdmed]
    \draw (\qfdLeftEdge, 0) rectangle (\qfdNH, -\qfdNW);
    \ifqfdshowimportance \draw (-\qfdImpW, 0) -- (-\qfdImpW, -\qfdNW); \fi
    \draw (0, 0) -- (0, -\qfdNW);
    \ifqfdshowroof
      \draw (0, 0) rectangle (\qfdNH, \qfdHdrH); \fi
    \ifqfdshowbasement
      \draw (0, -\qfdNW) rectangle (\qfdNH, -\qfdNW-\qfdBasementN); \fi
    \ifqfdshowcompetitive
      \draw (\qfdNH, 0) rectangle (\qfdNH+\qfdCmpW, -\qfdNW); \fi
  \end{scope}
}

\newcommand{\qfdDrawLegend}{%
  \ifqfdshowlegend
    \pgfmathsetmacro{\qfdLegX}{%
      \qfdNH + \ifqfdshowcompetitive \qfdCmpW + 0.7 \else 0.7 \fi}
    \pgfmathsetmacro{\qfdLegBottom}{%
      -2.05
      \ifqfdshowroof    \ifqfdshowcorrlegend - 2.55 \fi \fi
      \ifqfdshowcompetitive \ifqfdshowevallegend - 2.20 \fi \fi}
    \pgfmathsetmacro{\qfdLegY}{\qfdHdrH - 0.4}
    \begin{scope}[shift={(\qfdLegX, \qfdLegY)}]
      \draw[qfdmed, rounded corners=2pt]
        (-0.15, 0.4) rectangle (4.5, \qfdLegBottom);
      \node[anchor=west, font=\footnotesize\bfseries] at (0, 0.1)
        {\qfdRelTitle};
      \draw[qfdthin] (0, -0.15) -- (4.35, -0.15);
      \node[qfdstrong] at (0.22, -0.5)  {};
        \node[anchor=west] at (0.5, -0.5)  {Strong (9)};
      \node[qfdmod]    at (0.22, -0.95) {};
        \node[anchor=west] at (0.5, -0.95) {Medium (3)};
      \node[qfdweak]   at (0.22, -1.4)  {};
        \node[anchor=west] at (0.5, -1.4)  {Weak (1)};
      \ifqfdshowroof \ifqfdshowcorrlegend
        \node[anchor=west, font=\footnotesize\bfseries] at (0, -2.10)
          {\qfdCorrTitle};
        \draw[qfdthin] (0, -2.35) -- (4.35, -2.35);
        \node[anchor=west] at (0, -2.70) {{$+\!+$}\quad very positive};
        \node[anchor=west] at (0, -3.05) {{$+$\phantom{$+$}}\quad positive};
        \node[anchor=west] at (0, -3.40) {{$-$\phantom{$-$}}\quad negative};
        \node[anchor=west] at (0, -3.75) {{$-\!-$}\quad very negative};
      \fi \fi
      \ifqfdshowcompetitive \ifqfdshowevallegend
        \pgfmathsetmacro{\qfdEvalTop}{%
          -2.10 \ifqfdshowroof\ifqfdshowcorrlegend - 2.55 \fi\fi}
        \node[anchor=west, font=\footnotesize\bfseries]
          at (0, \qfdEvalTop) {\qfdEvalTitle};
        \pgfmathsetmacro{\qfdEvalSep}{\qfdEvalTop - 0.25}
        \draw[qfdthin] (0, \qfdEvalSep) -- (4.35, \qfdEvalSep);
        \pgfmathsetmacro{\qfdLegA}{\qfdEvalTop - 0.55}
        \draw[qfdalt1ln] (0.05, \qfdLegA) -- (0.45, \qfdLegA);
          \node[qfdalt1mk] at (0.25, \qfdLegA) {};
          \node[anchor=west, font=\bfseries] at (0.55, \qfdLegA)
            {\qfdAltOneLabel};
        \pgfmathsetmacro{\qfdLegB}{\qfdEvalTop - 0.95}
        \draw[qfdalt2ln] (0.05, \qfdLegB) -- (0.45, \qfdLegB);
          \node[qfdalt2mk] at (0.25, \qfdLegB) {};
          \node[anchor=west] at (0.55, \qfdLegB) {\qfdAltTwoLabel};
        \pgfmathsetmacro{\qfdLegC}{\qfdEvalTop - 1.35}
        \draw[qfdalt3ln] (0.05, \qfdLegC) -- (0.45, \qfdLegC);
          \node[qfdalt3mk] at (0.25, \qfdLegC) {};
          \node[anchor=west] at (0.55, \qfdLegC) {\qfdAltThreeLabel};
      \fi \fi
    \end{scope}
  \fi
}

\newenvironment{qfdhouse}{%
  \begin{tikzpicture}[x=1cm, y=1cm, font=\scriptsize,
                      line cap=round, line join=round]
  \ifqfdshowimportance
    \pgfmathsetmacro{\qfdLeftEdge}{-\qfdWhatW-\qfdImpW}
  \else
    \pgfmathsetmacro{\qfdLeftEdge}{-\qfdWhatW}
  \fi
  \pgfmathsetmacro{\qfdApexY}{\qfdHdrH + \qfdNH/2}
  \pgfmathtruncatemacro{\qfdNHm}{\qfdNH - 1}
  \pgfmathtruncatemacro{\qfdNWm}{\qfdNW - 1}
  \qfdDrawGrid
  \qfdDrawRoof
  \qfdDrawScale
  \qfdDrawZoneTitles
  \qfdDrawTitle
}{%
  \qfdDrawFrames
  \qfdDrawLegend
  \end{tikzpicture}%
}

% --- Product Radar overrides ---
\def\qfdNW{4}
\def\qfdNH{6}
\def\qfdWhatW{3.8}
\def\qfdHdrH{3.6}
\def\qfdWhatsTitle{Goals}
\def\qfdImpTitle{Imp.}
\qfdshowcompetitivefalse

\def\qfdProjectTitle{Product Radar}
\def\qfdConcept{Score up to 5 \textbf{Profiles} against shared
  \textbf{Criteria} on a \textbf{radar chart} to position a product
  at a glance, then share it as a self-contained link.}

\begin{document}
\begin{qfdhouse}
  % --- WHATs (Goals) + importance weights (1-10) ---
  \pgfmathsetmacro{\qfdWhatTextW}{\qfdWhatW - 0.2}
  \foreach \r/\t in {1/{Position at a glance}, 2/{Communicate it},
                     3/{Zero-setup persistence}, 4/{Low-friction editing}}
    \node[anchor=west, font=\scriptsize, text width=\qfdWhatTextW cm, align=left]
      at ({\qfdLeftEdge + 0.1}, {-\r + 0.5}) {\t};
  \foreach \r/\imp in {1/10, 2/8, 3/7, 4/6}
    \node[font=\scriptsize] at ({-\qfdImpW/2}, {-\r + 0.5}) {\imp};

  % --- HOWs (Functions, rotated) ---
  \foreach \c/\t in {1/{Live chart redraw}, 2/{Distinct Profiles},
                     3/{Faithful PNG export}, 4/{Self-contained link},
                     5/{Browser-local library}, 6/{Valid, fluid editing}}
    \node[rotate=90, anchor=west, font=\scriptsize] at ({\c - 0.5}, 0.15) {\t};

  % --- Relations (Goals x Functions) ---
  \node[qfdrel/S] at ({1 - 0.5}, {-1 + 0.5}) {};
  \node[qfdrel/S] at ({2 - 0.5}, {-1 + 0.5}) {};
  \node[qfdrel/M] at ({6 - 0.5}, {-1 + 0.5}) {};
  \node[qfdrel/M] at ({2 - 0.5}, {-2 + 0.5}) {};
  \node[qfdrel/S] at ({3 - 0.5}, {-2 + 0.5}) {};
  \node[qfdrel/S] at ({4 - 0.5}, {-2 + 0.5}) {};
  \node[qfdrel/W] at ({4 - 0.5}, {-3 + 0.5}) {};
  \node[qfdrel/S] at ({5 - 0.5}, {-3 + 0.5}) {};
  \node[qfdrel/S] at ({1 - 0.5}, {-4 + 0.5}) {};
  \node[qfdrel/M] at ({5 - 0.5}, {-4 + 0.5}) {};
  \node[qfdrel/S] at ({6 - 0.5}, {-4 + 0.5}) {};

  % --- Roof correlations (Function x Function) ---
  \node[font=\scriptsize] at (C-1-2) {$+$};
  \node[font=\scriptsize] at (C-1-3) {$+\!+$};
  \node[font=\scriptsize] at (C-1-5) {$-$};
  \node[font=\scriptsize] at (C-1-6) {$+\!+$};
  \node[font=\scriptsize] at (C-2-3) {$+$};
  \node[font=\scriptsize] at (C-3-4) {$+$};
  \node[font=\scriptsize] at (C-4-5) {$+$};
  \node[font=\scriptsize] at (C-4-6) {$+$};
  \node[font=\scriptsize] at (C-5-6) {$+$};

  % --- Basement: target / difficulty / abs weight / rel weight % ---
  \foreach \r/\lbl in {0/Target, 1/Difficulty, 2/Abs.\ wt, 3/Rel.\ wt \%}
    \node[anchor=east, font=\scriptsize\itshape]
      at ({-0.2}, {-\qfdNW - \r - 0.5}) {\lbl};
  \foreach \c/\tgt/\diff/\abs/\rel in
    {1/{$\leq$100ms}/2/144/25,
     2/{5 distinct}/2/114/20,
     3/{$\geq$2$\times$}/3/72/12,
     4/{lossless}/3/79/14,
     5/{0 network}/2/81/14,
     6/{$\leq$1 click}/3/84/15} {
    \node[font=\scriptsize] at ({\c - 0.5}, {-\qfdNW - 0.5}) {\tgt};
    \node[font=\scriptsize] at ({\c - 0.5}, {-\qfdNW - 1.5}) {\diff};
    \node[font=\scriptsize] at ({\c - 0.5}, {-\qfdNW - 2.5}) {\abs};
    \node[font=\scriptsize\bfseries] at ({\c - 0.5}, {-\qfdNW - 3.5}) {\rel};
  }
\end{qfdhouse}
\end{document}

1. Goals — the WHATs

ID Goal Weight Source
G1 Position a product against alternatives across several dimensions, readable in one glance 10 README.md, RadarChart.vue
G2 Communicate that positioning to others without them needing the app 8 utils/png.ts, utils/share.ts, ShareImport.vue
G3 Keep and revisit my radars with zero setup — no account, no server 7 storage.ts, RadarList.vue
G4 Build and adjust a radar with minimal friction 6 CriteriaEditor.vue, ScoreGrid.vue, utils/reorder.ts

Fairness of comparison (the "higher is better" discipline in CONTEXT.md) is treated as a constraint on G1, not a separate goal.

2. Functions — the HOWs

Direction: ↑ higher is better, ↓ lower is better, → fixed capability / invariant.

ID Function Dir Target (now) Target (future)
F1 Redraw the chart live as the Radar changes ≤ 100 ms per edit (feels instant) ≤ 50 ms
F2 Keep up to 5 Profiles distinguishable on one chart 5 Profiles × 7 Criteria, distinct palette colour + legend, no manual disambiguation
F3 Export the chart as a faithful raster image PNG ≥ 2× resolution, white bg, includes name + Concept, 1 click
F4 Encode an entire Radar into a self-contained link Lossless round-trip, no server, untrusted import validated & clamped
F5 Keep the whole Radar library in the browser 0 network calls, survives reload/restart, auto-save; open/duplicate/delete each ≤ 1 action
F6 Frictionless, always-valid editing Drag-reorder Criteria & Profiles; set a Score in ≤ 1 interaction; cardinality 37 / 15, Score 05, no nulls, unique names all unbreakable

3. Cascade — Goals → Functions → How → Components

  • G1 Position a product at a glance W:10
    • F1 Live chart redraw ↓ ≤100 ms
      • How: Vue 3 reactivity drives a hand-built inline SVG chart that recomputes polygon points from Scores.
        • Component: RadarChart.vue, Vue reactivity
      • How: canvas charting lib (Chart.js) or imperative D3 — rejected, see T1
    • F2 Distinguishable Profiles
      • How: fixed 8-colour PROFILE_PALETTE (≥ MAX_PROFILES) + legend + semi-transparent fills.
        • Component: PROFILE_PALETTE (types.ts), RadarChart.vue legend
  • G2 Communicate the positioning W:8
    • F3 Faithful raster export
      • How: serialise the live SVG → draw into a 2× <canvas>toBlob PNG; white bg; download or clipboard.
        • Component: utils/png.ts
      • How: server-side render / html2canvas / raw SVG download — rejected, see T2
    • F4 Self-contained share link
      • How: base64url(JSON(payload)) in /share?d= query; history-mode route; import validates & clamps.
        • Component: utils/share.ts, RadarEditor.vue (build link), ShareImport.vue, useRadars.importRadar, SPA fallback (nginx try_files, Pages 404.html)
      • How: server-stored short link, or compressed payload (LZ) — rejected, see T3
  • G3 Zero-setup persistence W:7
    • F5 Browser-local library
      • How: one localStorage key holds the Radar[]; deep watch auto-persists; CRUD via useRadars.
        • Component: storage.ts
      • How: IndexedDB, or backend sync — rejected, see T4
  • G4 Low-friction editing W:6
    • F6 Frictionless, always-valid editing ↓ / → invariant
      • How: pointer-based drag reorder + grid score entry; invariants enforced by MIN/MAX constants, ensureScores, and importRadar clamping.
        • Component: utils/reorder.ts, ScoreGrid.vue, CriteriaEditor.vue, ProfilesEditor.vue, storage.ensureScores

4. House — Goals × Functions

Cells: link strength (9 strong / 3 medium / 1 weak / blank none). Importance row = Σ(weight × strength).

F1 F2 F3 F4 F5 F6
G1 (10) 9 9 3
G2 (8) 3 9 9
G3 (7) 1 9
G4 (6) 9 3 9
Σ 144 114 72 79 81 84
Rel % 25 20 13 14 14 15

Top engineering priorities (from importance): the chart-rendering pair F1 + F2 carries 45% of the weight — they are the only functions that strongly serve the heaviest goal (G1 = 10), and F1 doubles into G4's editing feel. Sharing (F3 + F4 = 27%) is the most feature-visible part of the app but ranks below rendering: getting the chart live and legible is where correctness matters most. F3 (PNG), the headline README feature, ranks last by weight.

5. Roof — Function × Function tradeoffs

Symbols: strong reinforcement, mild reinforcement, × mild conflict, strong conflict.

F1 F2 F3 F4 F5 F6
F1 ×
F2
F3
F4
F5
F6

Conflicts that actually shape the design:

  • F1 × F5 (auto-save vs live redraw). Persistence deep-watches the whole radars array and synchronously JSON.stringifys the entire store on every change, so each Score edit pays a full-store serialize on the main thread — competing with F1's ≤100 ms redraw budget. Harmless at current scale; degrades as the library grows. Owned by the §7 budget (F1 row) and the §8 watched tension; no ADR yet.

Reinforcements that justify the architecture:

  • F1 ◎ F3 — the hand-built SVG chosen for F1 is what makes PNG export faithful and dependency-free; the T1 bet pays off twice.
  • F1 ◎ F6 — live redraw is the feedback loop that makes editing feel frictionless.
  • F4 ○ F6 — F6's cardinality caps (≤7 Criteria, ≤5 Profiles) bound the uncompressed payload, protecting F4's long-URL weakness.
  • F4 ○ F5 — share-import reuses F5's importRadar validation/clamping; one code path.

6. Components & anchors

Function → component mapping is in the §3 cascade tree. Components whose decision is anchored in an ADR:

ID Component Realises ADR
C1 RadarChart.vue (hand-built SVG) F1, F2
C2 utils/png.ts F3
C3 utils/share.ts + ShareImport.vue F4 ADR-0001
C4 storage.ts (useRadars) F5, F6
C5 utils/reorder.ts, editors, ScoreGrid.vue F6

7. Critical performance budget

Ranked from §4 importance and the §5 conflict. Each row names the fallback if the target is missed — a target without a fallback is a wish.

Rank Function Target Watched on If we miss it
1 F1 redraw ≤ 100 ms per edit devtools profile, 5×7 radar Debounce / decouple the deep-watch persist from the keystroke path (also resolves F1×F5)
2 F2 distinct 5 Profiles legible visual review at 5 Profiles Pattern fills in addition to colour + per-Profile show/hide toggle
3 F6 invariants states unbreakable hostile import + editor edge cases Centralise validation in one module (today split editor ↔ importRadar)
4 F4 link size fits URL limits URL length at 7×5 + names/Concept LZ-compress payload before base64; else fall back to a server short-link

8. Tradeoffs — Got / Paid / ADR

ID Tradeoff Got Paid ADR
T1 Hand-built SVG over a charting library Geometry control, exportable SVG (enables F3), tiny deps Must hand-build axes / legend / scaling
T2 Client SVG→canvas PNG over server / html2canvas Zero infra, faithful (chart is already SVG), no heavy deps Raster not vector; font-embedding & canvas-taint caveats
T3 Self-contained link over server short-link / compression Zero backend, links never expire, works on static hosting Long URLs, full data in URL, no revoke / edit-after-share ADR-0001
T4 localStorage over IndexedDB / backend Trivially simple, synchronous, enough at small scale ~5 MB quota; whole-store serialize per edit (F1×F5); single-origin

Tensions being watched (unresolved by design)

  • F1 × F5 store-serialize. Not optimising while the library is tiny. Trigger to revisit: a stored library large enough that a Score edit's full-store serialize is perceptible, or the ≤100 ms budget is breached in profiling.
  • Validation split across editor and importRadar (F6). Same rules live in two places; drift risk. Trigger: a third entry path appears, or the two diverge in a bug.
  • "Higher is better" is a hint, not enforced. Trigger: users repeatedly create inverted Criteria (e.g. Price instead of Affordability) and misread charts.

9. Inconsistencies spotted

  • README persistence claim. README.md says persistence is "IndexedDB or localStorage"; storage.ts uses localStorage only (see T4). Recommended resolution: correct the README to say localStorage. (Not changed in this design pass.)
  • Two deploy pipelines. Both Coolify/nginx and the GitHub Pages workflow (.github/workflows/deploy-pages.yml) deploy on push to main; the README documents only Coolify and the canonical domain (product-radar.apoena.dev vs the Pages custom domain) is unstated. Recommended resolution: state which target is canonical and whether Pages is a mirror. (Not changed in this design pass.)

How to keep this honest

  • When a new ADR lands → add its components to §6 and re-score affected rows.
  • When a spike / measurement returns numbers → update §7 Target / Watched on.
  • WHATs change rarely; HOWs change with each release; matrices (§§46) are recomputed when either side changes.
  • If a section becomes empty after edits, delete it — empty sections lie.