Compare commits

...

20 Commits

Author SHA1 Message Date
Julien Calixte
34219a6bdc fix: images now show full title
Some checks failed
Deploy to GitHub Pages / build (push) Failing after 23s
Deploy to GitHub Pages / deploy (push) Has been skipped
2026-07-10 14:41:30 +01:00
Julien Calixte
fbfbbdc0be test(completion): cover bracket-header block separation
Some checks failed
Deploy to GitHub Pages / build (push) Failing after 1m7s
Deploy to GitHub Pages / deploy (push) Has been skipped
2026-07-10 14:30:30 +01:00
Julien Calixte
b17de475ff fix(completion): separate a new block from the one above on accept
Accepting a bracketed [[…]] header typed directly under a block welded
the two together. When no blank line sits above, the insert now carries
a leading newline (never at the top of the document).
2026-07-10 14:30:30 +01:00
Julien Calixte
f7e856b082 test(editor): cover Enter-accepts and Escape newline semantics 2026-07-10 14:30:24 +01:00
Julien Calixte
8c14ae9c42 feat(editor): always accept completions on Enter
Enter now accepts the highlighted item like Tab whenever the popup is
open; dismiss with Escape first to insert a plain newline. Drops the
`engaged` gate that only accepted after navigating the menu.
2026-07-10 14:30:24 +01:00
Julien Calixte
f535c9ede5 feat(grid): widen the feature name column so longer names show 2026-07-10 14:30:13 +01:00
Julien Calixte
616a4c4301 docs(context): add the format-version term to the glossary
Some checks failed
Deploy to GitHub Pages / build (push) Failing after 22s
Deploy to GitHub Pages / deploy (push) Has been skipped
2026-07-02 22:05:00 +02:00
Julien Calixte
83f04af8d7 test(format): cover version, uniqueness, and example conformance 2026-07-02 22:04:20 +02:00
Julien Calixte
53716a13ea feat(format): read and emit macroplan_version, require unique names
Reject a macroplan_version newer than this build understands rather than
silently mis-rendering it. Feature names are the join key for a
milestone's requires, so duplicates (previously resolved to the first
match) are now a parse error. The sample plan emits the version marker.
2026-07-02 22:04:15 +02:00
Julien Calixte
d0a34ef28a docs(format): define the portable Macroplan TOML format v1
Document the schema, value types, and render semantics so a Macroplan
.toml can be produced and consumed outside this app's renderer. Ships a
JSON Schema (served from public/ for editor use) and a reference file.
2026-07-02 22:04:06 +02:00
Julien Calixte
e380e6d336 test(completion): cover popup viewport fitting geometry
Some checks failed
Deploy to GitHub Pages / build (push) Failing after 24s
Deploy to GitHub Pages / deploy (push) Has been skipped
2026-07-02 21:45:41 +02:00
Julien Calixte
9ee7c05019 fix(completion): keep the popup inside the window near screen edges
Anchoring the popup below the caret let it spill off the right/bottom of
the window when the caret sat at the edge, so the browser clipped it.
After render, measure the popup and shift it left off the right edge or
flip it above the caret line when there's no room below.
2026-07-02 21:45:35 +02:00
Julien Calixte
16b07844a8 test(editor): cover Tab/Enter completion accept semantics
Some checks failed
Deploy to GitHub Pages / build (push) Failing after 44s
Deploy to GitHub Pages / deploy (push) Has been skipped
Mounts PlanEditor under happy-dom and drives the popup: Tab accepts an
untouched menu, Enter closes it without accepting, and Enter accepts once
the selection has been navigated.
2026-07-02 09:39:37 +02:00
Julien Calixte
f20935a85b style(design): drop a stray blank line before the performance budget
Some checks failed
Deploy to GitHub Pages / deploy (push) Has been cancelled
Deploy to GitHub Pages / build (push) Has been cancelled
2026-07-02 09:37:08 +02:00
Julien Calixte
96c3f489a3 feat(editor): accept completions on Enter only after navigating
Enter kept hijacking the auto-opened popup mid-typing. Now Tab always
accepts, while Enter accepts only once the author has moved the selection
(arrow / Ctrl-N/P); an untouched popup lets Enter insert a newline.
2026-07-02 09:37:02 +02:00
Julien Calixte
449be7766e fix(completion): gate block headers behind a blank line
The popup offered [[feature]]/[[milestone]] on every fresh line — right
under the last property or straight after a header. Only volunteer them
once there's a blank line above (the "two new lines" convention); typing
a bare `[` still summons them anywhere. Property keys are unchanged.
2026-07-02 09:36:53 +02:00
Julien Calixte
db77314a88 Merge jcalixte/main into main
Some checks failed
Deploy to GitHub Pages / build (push) Failing after 22s
Deploy to GitHub Pages / deploy (push) Has been skipped
Reconcile the divergent house-of-quality work. Conflict in DESIGN.md
was the preamble's default \qfdHdrH (2.6 from the canonical resync vs
3.5 from the incoming label-overflow fix): keep the preamble at the
canonical 2.6 and move the 3.5 sizing override into the Macroplan
house block where the other house-specific dims live. Same render.
2026-06-25 11:45:31 +01:00
Julien Calixte
c72a95d942 docs(design): title the House of Quality with name and concept
Some checks failed
Deploy to GitHub Pages / build (push) Failing after 1m8s
Deploy to GitHub Pages / deploy (push) Has been skipped
2026-06-25 11:42:11 +01:00
Julien Calixte
e392eca51b docs(design): resync House of Quality preamble to canonical reference
The embedded preamble had drifted from the qfd skill's reference: it
lacked the title-block machinery, section comments, and sizing notes.
Replace it verbatim so future edits don't fight stale scaffolding.
2026-06-25 11:41:50 +01:00
Julien Calixte
e9c9f04d68 docs(design): move house of quality before goals, tighten F1 target
House now leads the document as the quick-reference overview; numbered
sections follow as the prose backing. \qfdHdrH raised 2.6 → 3.5 so F2/F3
labels no longer overflow the roof. F1 legibility target tightened to ≤1s
throughout (§2 table, §3 cascade, tikz basement, §7 budget).
2026-06-21 12:52:39 +02:00
17 changed files with 1080 additions and 336 deletions

View File

@@ -48,6 +48,10 @@ _Avoid_: today marker, cursor
The collection of saved **Macroplans** held in the browser's localStorage — the live store. Always holds at least one Macroplan; durability rests on exporting a Macroplan's `.toml` (per ADR-0002), not on the Library itself. Carries no status of its own. The collection of saved **Macroplans** held in the browser's localStorage — the live store. Always holds at least one Macroplan; durability rests on exporting a Macroplan's `.toml` (per ADR-0002), not on the Library itself. Carries no status of its own.
_Avoid_: workspace, project, file list _Avoid_: workspace, project, file list
**Format version**:
Which revision of the portable Macroplan `.toml` format a file targets, declared by an optional top-level `macroplan_version` (currently `1`). Absent means the current version; a newer version is rejected rather than silently mis-rendered. The format — its fields and render semantics — is defined in [docs/format.md](docs/format.md) so a Macroplan can be produced or consumed outside this app.
_Avoid_: schema version, file version, app version
## Symbols ## Symbols
- `┣` start of a Feature's bar - `┣` start of a Feature's bar

683
DESIGN.md
View File

@@ -6,6 +6,376 @@ Strength weights used in matrices: **9** strong, **3** medium, **1** weak, blank
--- ---
## House of Quality
```tikz
% =====================================================================
% QFD "House of Quality" preamble
% =====================================================================
% Usage:
% \begin{document}
% \begin{qfdhouse}
% % WHATs at ({\qfdLeftEdge + 0.1}, {-\r + 0.5})
% % Importance at ({-\qfdImpW/2}, {-\r + 0.5})
% % HOWs rotated 90 at ({\c - 0.5}, 0.15)
% % Relations in cells with [qfdrel/S], [qfdrel/M], [qfdrel/W]
% % Roof correlations at (C-i-j)
% % Perception markers at ({\qfdNH + (s+0.5)*\qfdCmpW/6}, {-\r + 0.5})
% % Basement: target / difficulty / abs weight / rel weight, per HOW
% \end{qfdhouse}
% \end{document}
%
% NB: in node text, use $<$ / $>$ for less-than / greater-than under
% OT1 encoding; bare < and > render as Spanish ¡ ¿.
% =====================================================================
\usetikzlibrary{arrows.meta, positioning, shapes.geometric, shapes.misc, calc, fit, backgrounds}
% Toggles — flip before \begin{qfdhouse} to hide sections.
\newif\ifqfdshowroof \qfdshowrooftrue
\newif\ifqfdshowbasement \qfdshowbasementtrue
\newif\ifqfdshowcompetitive \qfdshowcompetitivetrue
\newif\ifqfdshowlegend \qfdshowlegendtrue
\newif\ifqfdshowimportance \qfdshowimportancetrue
\newif\ifqfdshowcorrlegend \qfdshowcorrlegendtrue
\newif\ifqfdshowevallegend \qfdshowevallegendtrue
\newif\ifqfdshowtitle \qfdshowtitletrue % title block above the roof
% Dimensions — override before \begin{qfdhouse} to resize.
\def\qfdNW{5} % number of WHATs (rows)
\def\qfdNH{5} % number of HOWs (columns)
\def\qfdWhatW{4.0} % width of WHATs column
\def\qfdImpW{0.9} % width of importance column
\def\qfdCmpW{3} % width of perception zone
\def\qfdHdrH{2.6} % height of column-titles band
\def\qfdBasementN{4} % number of basement rows
% Titles & labels — override before \begin{qfdhouse}.
\def\qfdWhatsTitle{Customer needs}
\def\qfdImpTitle{Imp.\ \%}
\def\qfdPerceptionTitle{Comparative evaluation}
\def\qfdPoorLabel{poor}
\def\qfdExcellentLabel{excellent}
\def\qfdAltOneLabel{Our product} % highlighted in legend
\def\qfdAltTwoLabel{Competitor A}
\def\qfdAltThreeLabel{Competitor B}
\def\qfdRelTitle{Relation}
\def\qfdCorrTitle{Correlation}
\def\qfdEvalTitle{Evaluation}
% Title block above the roof — set both to name the house. Leave
% \qfdProjectTitle empty (default) to draw no title at all.
\def\qfdProjectTitle{} % project / feature name (large, bold)
\def\qfdConcept{} % concept in one sentence; \textbf{} the keywords
% Styles.
\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},
% Three perception-zone alternatives. Index 1 is emphasised.
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},
}
% --- Grid lines for every zone. ---
\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
}
% --- Roof: diagonal grid + named coordinates (C-i-j) for correlations. ---
\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
}
% --- Perception scale 0..5 + poor/excellent endpoints + zone title. ---
\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
}
% --- Importance title (left) and WHATs title (header band). ---
\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};
}
% --- Title + concept subtitle, centred above the roof apex. ---
\newcommand{\qfdDrawTitle}{%
\ifqfdshowtitle
\ifx\qfdProjectTitle\empty\else
\pgfmathsetmacro{\qfdTitleX}{\qfdNH/2}
\pgfmathsetmacro{\qfdTitleY}{\ifqfdshowroof \qfdApexY \else \qfdHdrH \fi + 0.9}
\pgfmathsetmacro{\qfdSubW}{\qfdNH + 2} % pre-computed (braces don't do arithmetic)
\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
}
% --- Outer frames around each zone. ---
\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}
}
% --- Legend on the right (Relations / Correlations / Evaluation). ---
\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);
% Relations
\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)};
% Correlations (roof)
\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
% Evaluation (3 alternatives)
\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
}
% --- The environment users wrap their content in. ---
\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}%
}
% --- Macroplan house: 5 WHATs, 7 HOWs, no competitor zone ---
\def\qfdNW{5}
\def\qfdNH{7}
\def\qfdWhatW{4.6}
\def\qfdHdrH{3.5} % taller header band — long HOW labels (~24 chars)
\def\qfdWhatsTitle{Goals (WHATs)}
\def\qfdImpTitle{Weight}
\def\qfdProjectTitle{Macroplan}
\def\qfdConcept{A \textbf{week-granular} plan view that holds every
\textbf{Feature} honest against its \textbf{Original Estimate} and turns
the slips into captured \textbf{Learnings}.}
\qfdshowcompetitivefalse
\begin{document}
\begin{qfdhouse}
% WHATs + weights
\pgfmathsetmacro{\qfdWhatTextW}{\qfdWhatW - 0.2}
\foreach \r/\t in {%
1/{G1 Honest record vs Original Estimate},
2/{G2 Capture Learnings},
3/{G4 Fast authoring},
4/{G3 Milestone risk visible},
5/{G5 Shareable view}}
\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/8, 4/7, 5/5}
\node[font=\scriptsize] at ({-\qfdImpW/2}, {-\r + 0.5}) {\imp};
% HOWs (rotated)
\foreach \c/\t in {%
1/{F1 Legible render},
2/{F2 Classify on-time/late},
3/{F3 Instant edit-to-view},
4/{F4 Local CRUD},
5/{F5 Easy Learning},
6/{F6 Milestone render},
7/{F7 Image export}}
\node[rotate=90, anchor=west, font=\scriptsize]
at ({\c - 0.5}, 0.15) {\t};
% Relations
\node[qfdrel/S] at ({1 - 0.5}, {-1 + 0.5}) {};
\node[qfdrel/S] at ({2 - 0.5}, {-1 + 0.5}) {};
\node[qfdrel/W] at ({3 - 0.5}, {-1 + 0.5}) {};
\node[qfdrel/W] at ({4 - 0.5}, {-1 + 0.5}) {};
\node[qfdrel/W] at ({6 - 0.5}, {-1 + 0.5}) {};
\node[qfdrel/M] at ({1 - 0.5}, {-2 + 0.5}) {};
\node[qfdrel/S] at ({5 - 0.5}, {-2 + 0.5}) {};
\node[qfdrel/W] at ({1 - 0.5}, {-3 + 0.5}) {};
\node[qfdrel/S] at ({3 - 0.5}, {-3 + 0.5}) {};
\node[qfdrel/S] at ({4 - 0.5}, {-3 + 0.5}) {};
\node[qfdrel/M] at ({5 - 0.5}, {-3 + 0.5}) {};
\node[qfdrel/W] at ({6 - 0.5}, {-3 + 0.5}) {};
\node[qfdrel/M] at ({1 - 0.5}, {-4 + 0.5}) {};
\node[qfdrel/M] at ({2 - 0.5}, {-4 + 0.5}) {};
\node[qfdrel/S] at ({6 - 0.5}, {-4 + 0.5}) {};
\node[qfdrel/M] at ({1 - 0.5}, {-5 + 0.5}) {};
\node[qfdrel/S] at ({7 - 0.5}, {-5 + 0.5}) {};
% Roof correlations
\node[font=\scriptsize] at (C-1-2) {$+$};
\node[font=\scriptsize] at (C-1-3) {$-$};
\node[font=\scriptsize] at (C-3-4) {$+\!+$};
\node[font=\scriptsize] at (C-2-4) {$+$};
\node[font=\scriptsize] at (C-1-7) {$-$};
% Basement: target / difficulty / abs / rel
\foreach \c/\tgt/\diff/\abs/\rel in {%
1/{state $\leq$1s}/4/158/24,
2/{100\%}/2/111/17,
3/{$\leq$1s}/2/82/13,
4/{1 block}/2/82/13,
5/{1 field}/1/96/15,
6/{line@wk}/3/81/12,
7/{1-click}/3/45/7} {
\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}
```
Basement rows (top→bottom): **target · difficulty (15) · absolute weight · relative weight %**.
---
## 1. Goals — the WHATs ## 1. Goals — the WHATs
| ID | Goal | Weight | Source | | ID | Goal | Weight | Source |
@@ -20,7 +390,7 @@ Strength weights used in matrices: **9** strong, **3** medium, **1** weak, blank
| ID | Function | Dir | Target (now) | | ID | Function | Dir | Target (now) |
| --- | -------------------------------------------------------------------------------- | :-: | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | --- | -------------------------------------------------------------------------------- | :-: | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| F1 | Render the plan legibly — bars, symbols, status colors, now-line, hover comments | → | reader IDs any Feature's state (on-time/late/overdue/slip-count) in ≤3s; Feature name + week axis never lost even when the plan exceeds the viewport | | F1 | Render the plan legibly — bars, symbols, status colors, now-line, hover comments | → | reader IDs any Feature's state (on-time/late/overdue/slip-count) in ≤1s; Feature name + week axis never lost even when the plan exceeds the viewport |
| F2 | Classify each Delivery on-time/late against the Original Estimate | → | 100% correct per ADR-0001; markers land on the right Week | | F2 | Classify each Delivery on-time/late against the Original Estimate | → | 100% correct per ADR-0001; markers land on the right Week |
| F3 | Reflect a source edit in the rendered view | ↓ | ≤1s (live reload while authoring) | | F3 | Reflect a source edit in the rendered view | ↓ | ≤1s (live reload while authoring) |
| F4 | Add / edit / remove a Feature with a single local edit | ↓ | one contiguous block per op; no ripple edits — Weeks auto-layout, Milestones reference Features by name | | F4 | Add / edit / remove a Feature with a single local edit | ↓ | one contiguous block per op; no ripple edits — Weeks auto-layout, Milestones reference Features by name |
@@ -31,7 +401,7 @@ Strength weights used in matrices: **9** strong, **3** medium, **1** weak, blank
## 3. Cascade — Goals → Functions → How → Components ## 3. Cascade — Goals → Functions → How → Components
- **G1** See where every Feature stands vs. its Original Estimate — the honest record _W:10_ - **G1** See where every Feature stands vs. its Original Estimate — the honest record _W:10_
- **F1** Render the plan legibly (incl. at scale) _Dir→ reader IDs a state ≤3s; name + axis never lost_ - **F1** Render the plan legibly (incl. at scale) _Dir→ reader IDs a state ≤1s; name + axis never lost_
- **How**: DOM rendering with CSS Grid (symbols are the visual vocabulary _inside_ cells; layout is real DOM) — chosen over a preformatted monospace text block, which can't pin a column on scroll. See T1. - **How**: DOM rendering with CSS Grid (symbols are the visual vocabulary _inside_ cells; layout is real DOM) — chosen over a preformatted monospace text block, which can't pin a column on scroll. See T1.
- **Component**: **C3 Grid renderer** — bars (`┣━`), markers (`◯△◉▲`), status colors, Now line, sticky name column + sticky week-header row, hover tooltips for status notes - **Component**: **C3 Grid renderer** — bars (`┣━`), markers (`◯△◉▲`), status colors, Now line, sticky name column + sticky week-header row, hover tooltips for status notes
- **G2** Turn estimation misses into captured Learnings _W:8_ - **G2** Turn estimation misses into captured Learnings _W:8_
@@ -121,319 +491,12 @@ Strength of each Component in realising each Function (9/3/1/blank). Component l
**C3 (Grid renderer)** and **C2 (Plan model)** each anchor three functions — they're the load-bearing components and the most important to get right and test hard. **C3 (Grid renderer)** and **C2 (Plan model)** each anchor three functions — they're the load-bearing components and the most important to get right and test hard.
### House of Quality (rendered)
```tikz
% =====================================================================
% 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
\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}
\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{\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
}{%
\qfdDrawFrames
\qfdDrawLegend
\end{tikzpicture}%
}
% --- Macroplan house: 5 WHATs, 7 HOWs, no competitor zone ---
\def\qfdNW{5}
\def\qfdNH{7}
\def\qfdWhatW{4.6}
\def\qfdWhatsTitle{Goals (WHATs)}
\def\qfdImpTitle{Weight}
\qfdshowcompetitivefalse
\begin{document}
\begin{qfdhouse}
% WHATs + weights
\pgfmathsetmacro{\qfdWhatTextW}{\qfdWhatW - 0.2}
\foreach \r/\t in {%
1/{G1 Honest record vs Original Estimate},
2/{G2 Capture Learnings},
3/{G4 Fast authoring},
4/{G3 Milestone risk visible},
5/{G5 Shareable view}}
\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/8, 4/7, 5/5}
\node[font=\scriptsize] at ({-\qfdImpW/2}, {-\r + 0.5}) {\imp};
% HOWs (rotated)
\foreach \c/\t in {%
1/{F1 Legible render},
2/{F2 Classify on-time/late},
3/{F3 Instant edit-to-view},
4/{F4 Local CRUD},
5/{F5 Easy Learning},
6/{F6 Milestone render},
7/{F7 Image export}}
\node[rotate=90, anchor=west, font=\scriptsize]
at ({\c - 0.5}, 0.15) {\t};
% Relations
\node[qfdrel/S] at ({1 - 0.5}, {-1 + 0.5}) {};
\node[qfdrel/S] at ({2 - 0.5}, {-1 + 0.5}) {};
\node[qfdrel/W] at ({3 - 0.5}, {-1 + 0.5}) {};
\node[qfdrel/W] at ({4 - 0.5}, {-1 + 0.5}) {};
\node[qfdrel/W] at ({6 - 0.5}, {-1 + 0.5}) {};
\node[qfdrel/M] at ({1 - 0.5}, {-2 + 0.5}) {};
\node[qfdrel/S] at ({5 - 0.5}, {-2 + 0.5}) {};
\node[qfdrel/W] at ({1 - 0.5}, {-3 + 0.5}) {};
\node[qfdrel/S] at ({3 - 0.5}, {-3 + 0.5}) {};
\node[qfdrel/S] at ({4 - 0.5}, {-3 + 0.5}) {};
\node[qfdrel/M] at ({5 - 0.5}, {-3 + 0.5}) {};
\node[qfdrel/W] at ({6 - 0.5}, {-3 + 0.5}) {};
\node[qfdrel/M] at ({1 - 0.5}, {-4 + 0.5}) {};
\node[qfdrel/M] at ({2 - 0.5}, {-4 + 0.5}) {};
\node[qfdrel/S] at ({6 - 0.5}, {-4 + 0.5}) {};
\node[qfdrel/M] at ({1 - 0.5}, {-5 + 0.5}) {};
\node[qfdrel/S] at ({7 - 0.5}, {-5 + 0.5}) {};
% Roof correlations
\node[font=\scriptsize] at (C-1-2) {$+$};
\node[font=\scriptsize] at (C-1-3) {$-$};
\node[font=\scriptsize] at (C-3-4) {$+\!+$};
\node[font=\scriptsize] at (C-2-4) {$+$};
\node[font=\scriptsize] at (C-1-7) {$-$};
% Basement: target / difficulty / abs / rel
\foreach \c/\tgt/\diff/\abs/\rel in {%
1/{state $\leq$3s}/4/158/24,
2/{100\%}/2/111/17,
3/{$\leq$1s}/2/82/13,
4/{1 block}/2/82/13,
5/{1 field}/1/96/15,
6/{line@wk}/3/81/12,
7/{1-click}/3/45/7} {
\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}
```
Basement rows (top→bottom): **target · difficulty (15) · absolute weight · relative weight %**.
## 7. Critical performance budget ## 7. Critical performance budget
| Rank | Function | Target | Watched on | If we miss it | | Rank | Function | Target | Watched on | If we miss it |
| ---- | -------- | ------------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | | ---- | -------- | ------------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| 1 | F2 | 100% correct on-time/late vs Original Estimate | unit tests over sample plans (incl. multi-slip, deliver-early, overdue) | classification is the product — block release; it's pure logic, so a failing test is a hard stop | | 1 | F2 | 100% correct on-time/late vs Original Estimate | unit tests over sample plans (incl. multi-slip, deliver-early, overdue) | classification is the product — block release; it's pure logic, so a failing test is a hard stop |
| 2 | F1 | reader IDs a state ≤3s; legible past the viewport | manual review on a 30-feature / 26-week sample; check sticky panes | drop hover-only data into always-visible cells; simplify symbol styling | | 2 | F1 | reader IDs a state ≤1s; legible past the viewport | manual review on a 30-feature / 26-week sample; check sticky panes | drop hover-only data into always-visible cells; simplify symbol styling |
| 3 | F3 | ≤1s edit→view | eyeball on keystroke with a large plan | debounce parse; parse only changed blocks | | 3 | F3 | ≤1s edit→view | eyeball on keystroke with a large plan | debounce parse; parse only changed blocks |
| 4 | F7 | one-click PNG, faithful to on-screen | manual export of a real plan; diff against screen | fall back to download-only if clipboard API is flaky; document "what you see is what exports" | | 4 | F7 | one-click PNG, faithful to on-screen | manual export of a real plan; diff against screen | fall back to download-only if clipboard API is flaky; document "what you see is what exports" |

View File

@@ -31,7 +31,8 @@ Dashboard ┣━━━━━◯ 🔴 n
## Documentation ## Documentation
| Document | What it covers | | Document | What it covers |
| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [docs/format.md](docs/format.md) | **The Macroplan TOML format (v1)** — the portable, tool-independent schema: fields, value types, the `macroplan_version` marker, and the render semantics a consumer must honor. Ships a [JSON Schema](public/macroplan.schema.json) and a [reference `.toml`](docs/macroplan.example.toml). |
| [CONTEXT.md](CONTEXT.md) | **Ubiquitous language** — the glossary: Macroplan, Feature, Original Estimate, Re-estimate, Delivery, Milestone, Week, Now line, Learning, Status, and the symbol legend | | [CONTEXT.md](CONTEXT.md) | **Ubiquitous language** — the glossary: Macroplan, Feature, Original Estimate, Re-estimate, Delivery, Milestone, Week, Now line, Learning, Status, and the symbol legend |
| [DESIGN.md](DESIGN.md) | **Goal-driven design (QFD)** — goals, functions, the Goal→Function→How→Component cascade, the House/Roof matrices + rendered House of Quality, critical performance budget, trade-offs, and watched tensions | | [DESIGN.md](DESIGN.md) | **Goal-driven design (QFD)** — goals, functions, the Goal→Function→How→Component cascade, the House/Roof matrices + rendered House of Quality, critical performance budget, trade-offs, and watched tensions |
| [docs/adr/0001-original-estimate-as-baseline.md](docs/adr/0001-original-estimate-as-baseline.md) | Why on-time/late is judged against the **Original Estimate**, never a re-estimate | | [docs/adr/0001-original-estimate-as-baseline.md](docs/adr/0001-original-estimate-as-baseline.md) | Why on-time/late is judged against the **Original Estimate**, never a re-estimate |

154
docs/format.md Normal file
View File

@@ -0,0 +1,154 @@
# Macroplan TOML Format — v1
The portable, tool-independent definition of a **Macroplan**: a week-by-week
delivery plan that records what a team promised, every time an estimate slipped,
and when work actually shipped — judged against the **first** estimate, never a
moved goalpost.
This document is the contract. Any tool can produce or consume a `.toml` file
that conforms to it and render the same plan. The [macroplan app](../README.md)
is one such consumer; nothing here is specific to it.
- **Machine-readable schema:** [`macroplan.schema.json`](../public/macroplan.schema.json),
served at `https://macroplan.apoena.dev/macroplan.schema.json` (JSON Schema
2020-12; validates the TOML-decoded structure).
- **Reference file:** [`macroplan.example.toml`](macroplan.example.toml)
(exercises every field and state; a conformance sample).
- **Serialization:** [TOML v1.0.0](https://toml.io). Values use TOML's native
types — dates are **bare date literals**, not quoted strings (see _Dates_).
## Versioning
A file MAY declare the format version at the top level:
```toml
macroplan_version = 1
```
- **Optional.** An absent marker means the current version (`1`). Every existing
file therefore stays valid.
- **Integer**, monotonically increasing. This document defines version **1**.
- A consumer that understands version _N_ **MUST reject** a file declaring a
version **greater than** _N_ (rather than silently mis-rendering it), and
SHOULD accept any version ≤ _N_ it still supports.
- The version is bumped only for a **breaking** change to the schema or render
semantics below — a new optional field does not require a bump.
Producers SHOULD emit `macroplan_version` so a file self-describes its target.
## Top-level fields
| Key | Required | Type | Default | Meaning |
| ------------------- | -------- | ------------- | ---------------------- | ------------------------------------------------------------------------------------- |
| `macroplan_version` | no | integer (`1`) | `1` | Format version (see above). |
| `title` | no | string | `"Untitled Macroplan"` | Display name of the plan. |
| `start` | no | date | auto-fit | Left edge of the plan's week span. **Only extends** the span; never clips a feature. |
| `end` | no | date | auto-fit | Right edge of the plan's week span. **Only extends** the span; never clips a feature. |
Zero or more `[[feature]]` blocks and zero or more `[[milestone]]` blocks follow.
## `[[feature]]`
A unit of work, read left-to-right as a story: where it started, what was first
promised, every slip, and when it shipped.
| Key | Required | Type | Default | Meaning |
| ------------- | -------- | -------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `name` | **yes** | non-empty string | — | Identifies the feature; the **join key** for a milestone's `requires`. |
| `start` | **yes** | date | — | The week work began. |
| `original` | **yes** | date | — | The **Original Estimate** — the immovable baseline all lateness is judged against ([ADR-0001](adr/0001-original-estimate-as-baseline.md)). |
| `reestimates` | no | array of dates | `[]` | Each later week the estimate slipped to (a slip, `△`). Order-independent. |
| `delivered` | no | date | — | The week the feature actually shipped. Absent → still in flight. |
| `learning` | no | string | — | A post-delivery takeaway. |
| `status` | no | `"on-track"` \| `"at-risk"` \| `"off-track"` | — | In-flight confidence. Meaningful only while undelivered. |
| `note` | no | string | — | A short note accompanying `status`. |
**Constraint — unique names:** feature `name` values MUST be unique within a
plan. Because milestone `requires` resolves by name, a duplicate would make a
requirement ambiguous. Conforming consumers reject duplicates.
## `[[milestone]]`
A dated checkpoint that depends on a set of features being delivered by then.
| Key | Required | Type | Default | Meaning |
| ---------- | -------- | ---------------- | ------- | ------------------------------------------------------------------- |
| `name` | **yes** | non-empty string | — | Display name of the milestone. |
| `week` | **yes** | date | — | When the milestone falls due. |
| `requires` | no | array of strings | `[]` | Feature `name`s that must be delivered on/before `week` to meet it. |
A `requires` entry that names no existing feature counts as **unmet** (it can
never be satisfied) — useful for catching typos.
## Value types
### Dates → weeks
Every date field is a **calendar date**, written as a TOML bare date literal:
```toml
original = 2026-06-15
reestimates = [2026-06-29, 2026-07-13]
```
- **Canonical form is the bare date literal.** A quoted `"2026-06-15"` string is
_tolerated_ on input for convenience, but producers SHOULD emit bare literals
so the same plan always serializes identically.
- **Any day snaps to its ISO-week Monday.** A Macroplan's unit is the week, keyed
by the Monday (yyyy-mm-dd) of the ISO week containing the date. `2026-06-17`
(a Wednesday) and `2026-06-15` (that Monday) denote the **same week**. Every
conforming renderer MUST apply this snapping, or dates land in the wrong column.
### Status
The enum `on-track` \| `at-risk` \| `off-track`. No other value is valid. Lateness
is **derived, never authored** — there is deliberately no "late" status.
## Render semantics (the contract a renderer must honor)
Two tools should draw the same plan from the same file. These rules are as much
a part of the format as the fields:
1. **On-time vs. late is judged only against `original`** — never a re-estimate
([ADR-0001](adr/0001-original-estimate-as-baseline.md)). `delivered ≤ original`
(by week) is on time; `delivered > original` is late.
2. **Marker vocabulary** per feature:
- `◯` **original** — the Original Estimate week, while undelivered.
- `△` **re-estimate** — one per `reestimates` entry.
- `◉` **delivered on time** — a delivery on/before `original`. It **subsumes**
the `◯` (no separate original marker is drawn).
- `▲` **delivered late** — a delivery after `original`; the `◯` baseline is
**kept** alongside it.
3. **Bar extent.** A feature's bar runs from `start` to its furthest marker. A
delivered bar ends at its delivery. An **overdue** feature (undelivered, and
already past its furthest estimate relative to "now") keeps running to the
current week.
4. **Plan span.** The rendered week range runs from the earliest to the latest
week among all features' markers and all milestones. Optional `start`/`end`
only widen this range with lead-in / trailing weeks; a marker outside them is
never clipped.
5. **Milestone met/unmet.** A required feature is **unmet** at a milestone if it
is undelivered, delivered _after_ the milestone `week`, or names no existing
feature.
6. **"Now".** The current week (Monday of today) drives overdue extension and the
"now" line. It is contextual, not stored in the file.
## Notes for implementers
- **Unknown keys.** The JSON Schema is strict (`additionalProperties: false`) so
editors flag typos like `titel`. The reference parser is more lenient — it
ignores unrecognized top-level keys. Producers SHOULD NOT emit unknown keys.
- **Empty plan.** A file with no features and no milestones is valid; with an
authored `start`/`end` it renders as an empty grid across that span.
- **Editor integration.** Point [Taplo](https://taplo.tamasfe.dev)-based editors
at the schema with a directive on the first line of a plan file:
`#:schema https://macroplan.apoena.dev/macroplan.schema.json`.
## See also
- [CONTEXT.md](../CONTEXT.md) — the ubiquitous language (Feature, Original
Estimate, Re-estimate, Delivery, Milestone, Week, Status, Learning).
- [ADR-0001](adr/0001-original-estimate-as-baseline.md) — why lateness is judged
against the Original Estimate.
- [ADR-0002](adr/0002-local-first-no-backend.md) — why the `.toml` file is the
portable source of truth.

View File

@@ -0,0 +1,79 @@
#:schema https://macroplan.apoena.dev/macroplan.schema.json
#
# Reference Macroplan — a conformance sample exercising every field and state.
# The authoritative definition of this format is docs/format.md.
# Format version. Optional; the current version is 1. Omitting it means "1".
macroplan_version = 1
title = "Q3 — Checkout revamp"
# Optional plan span: pad the plan with lead-in / trailing weeks.
# Rule: `start` only ever extends the span left, `end` extends it right;
# neither clips a feature. Omit both to auto-fit the columns.
start = 2026-05-25
end = 2026-08-03
# Dates are TOML date literals. Any day snaps to that week's Monday.
# On-time delivery: delivered on/before the Original Estimate → ◉ (subsumes ◯).
[[feature]]
name = "Auth"
start = 2026-06-01
original = 2026-06-15
delivered = 2026-06-15
learning = "Spiking the OAuth flow first paid off — do discovery spikes earlier."
# Late delivery with slips: ◯ baseline kept, one △ per re-estimate, then ▲.
# Late because delivery (07-20) is after `original` (06-15) — the re-estimates
# never move the goalpost (ADR-0001).
[[feature]]
name = "Payments"
start = 2026-06-01
original = 2026-06-15
reestimates = [2026-06-29, 2026-07-13]
delivered = 2026-07-20
learning = "Vendor lead time was the real constraint — derisk vendors up front."
# In-flight and overdue: undelivered, `original` already in the past → ◯ only,
# bar runs to "now". `status` + `note` express confidence while it's open.
[[feature]]
name = "Dashboard"
start = 2026-06-01
original = 2026-06-08
status = "off-track"
note = "No recovery plan yet — needs an owner."
# In-flight, re-estimated once: ◯ + △, still open.
[[feature]]
name = "Search"
start = 2026-06-08
original = 2026-06-22
reestimates = [2026-07-06]
status = "at-risk"
note = "Third-party search API is flaky; spike a fallback."
# In-flight, on track: the minimal feature — only the three required fields
# plus a status.
[[feature]]
name = "Notifications"
start = 2026-06-22
original = 2026-07-06
status = "on-track"
# Milestones tie a due week to the features that must ship by then.
[[milestone]]
name = "Code freeze"
week = 2026-06-29
requires = ["Auth", "Payments"]
[[milestone]]
name = "MVP go-live"
week = 2026-07-06
requires = ["Auth", "Payments", "Dashboard"]
# A milestone with no requirements is valid too.
[[milestone]]
name = "Beta launch"
week = 2026-07-13
requires = ["Search", "Notifications"]

View File

@@ -0,0 +1,108 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://macroplan.apoena.dev/macroplan.schema.json",
"title": "Macroplan",
"description": "A week-by-week delivery plan. Portable TOML format v1 — see docs/format.md. Validates the TOML-decoded structure; dates are TOML date literals, surfaced to JSON Schema as date-formatted strings.",
"type": "object",
"additionalProperties": false,
"properties": {
"macroplan_version": {
"description": "Format version. Omit for the current version (1). A newer version must be rejected by a consumer that only understands 1.",
"type": "integer",
"const": 1
},
"title": {
"description": "Display name of the plan. Defaults to \"Untitled Macroplan\" when absent.",
"type": "string"
},
"start": {
"description": "Optional left edge of the plan's week span. Only extends the span; never clips a feature. Snapped to its ISO-week Monday.",
"type": "string",
"format": "date"
},
"end": {
"description": "Optional right edge of the plan's week span. Only extends the span; never clips a feature. Snapped to its ISO-week Monday.",
"type": "string",
"format": "date"
},
"feature": {
"description": "The features of the plan. Written as [[feature]] blocks. Names must be unique.",
"type": "array",
"items": { "$ref": "#/$defs/feature" }
},
"milestone": {
"description": "Dated checkpoints. Written as [[milestone]] blocks.",
"type": "array",
"items": { "$ref": "#/$defs/milestone" }
}
},
"$defs": {
"date": {
"description": "A calendar date (TOML date literal, e.g. 2026-06-15). Snapped to its ISO-week Monday.",
"type": "string",
"format": "date"
},
"feature": {
"type": "object",
"additionalProperties": false,
"required": ["name", "start", "original"],
"properties": {
"name": {
"description": "Identifies the feature; the join key for a milestone's `requires`. Must be unique within the plan.",
"type": "string",
"minLength": 1
},
"start": {
"description": "The week work began.",
"$ref": "#/$defs/date"
},
"original": {
"description": "The Original Estimate — the immovable baseline all lateness is judged against (ADR-0001).",
"$ref": "#/$defs/date"
},
"reestimates": {
"description": "Each later week the estimate slipped to. Order-independent.",
"type": "array",
"items": { "$ref": "#/$defs/date" }
},
"delivered": {
"description": "The week the feature actually shipped. Absent means still in flight.",
"$ref": "#/$defs/date"
},
"learning": {
"description": "A post-delivery takeaway.",
"type": "string"
},
"status": {
"description": "In-flight confidence. Meaningful only while undelivered. Lateness is derived, never authored.",
"enum": ["on-track", "at-risk", "off-track"]
},
"note": {
"description": "A short note accompanying `status`.",
"type": "string"
}
}
},
"milestone": {
"type": "object",
"additionalProperties": false,
"required": ["name", "week"],
"properties": {
"name": {
"description": "Display name of the milestone.",
"type": "string",
"minLength": 1
},
"week": {
"description": "When the milestone falls due.",
"$ref": "#/$defs/date"
},
"requires": {
"description": "Feature names that must be delivered on/before `week` to meet the milestone.",
"type": "array",
"items": { "type": "string" }
}
}
}
}
}

View File

@@ -179,4 +179,12 @@ function confirmDelete() {
.export-root.exporting :deep(.macroplan) { .export-root.exporting :deep(.macroplan) {
overflow: visible; overflow: visible;
} }
/* A PNG has no hover, so text that ellipses on screen (feature names, learning /
status notes) must wrap to its full length in the capture instead of being cut.
The column width is unchanged, so the milestone band stays aligned. */
.export-root.exporting :deep(.namecell .truncate),
.export-root.exporting :deep(.learncell .note) {
white-space: normal;
overflow: visible;
}
</style> </style>

View File

@@ -46,7 +46,7 @@ const TONE_DOT: Record<Tone, string> = {
} }
// Layout constants (must match the CSS vars --name-w / --wk) for stacking math. // Layout constants (must match the CSS vars --name-w / --wk) for stacking math.
const NAME_W = 9 * 16 const NAME_W = 16 * 16
const WK = 3.5 * 16 const WK = 3.5 * 16
const BAND_CHAR = 6.6 // ≈ Fira Code advance (px) at the band font size const BAND_CHAR = 6.6 // ≈ Fira Code advance (px) at the band font size
@@ -224,7 +224,7 @@ const bandStyle = computed(() => ({
<style scoped> <style scoped>
.macroplan { .macroplan {
--name-w: 9rem; --name-w: 16rem;
--wk: 3.5rem; --wk: 3.5rem;
} }
.plan-grid { .plan-grid {

View File

@@ -0,0 +1,74 @@
// @vitest-environment happy-dom
import { describe, it, expect, beforeAll } from "vitest"
import { mount } from "@vue/test-utils"
import PlanEditor from "./PlanEditor.vue"
// happy-dom ships no canvas 2D context; measure() only needs measureText, so a
// tiny stub keeps onMounted from throwing without pulling in a canvas polyfill.
beforeAll(() => {
HTMLCanvasElement.prototype.getContext = (() => ({
measureText: () => ({ width: 80 }),
font: "",
})) as unknown as HTMLCanvasElement["getContext"]
})
/** Mount with a working v-model so emitted edits flow back into the prop. */
function mountEditor(initial = "") {
const wrapper = mount(PlanEditor, {
props: {
modelValue: initial,
error: null,
"onUpdate:modelValue": (v: string) => wrapper.setProps({ modelValue: v }),
},
})
return wrapper
}
/** Type `text` into the textarea (caret at the end) and let the popup refresh. */
async function type(wrapper: ReturnType<typeof mountEditor>, text: string) {
const ta = wrapper.find("textarea")
const el = ta.element as HTMLTextAreaElement
el.value = text
el.selectionStart = el.selectionEnd = text.length
await ta.trigger("input")
}
const lastEmit = (wrapper: ReturnType<typeof mountEditor>) => {
const events = wrapper.emitted("update:modelValue")
return events?.at(-1)?.[0] as string | undefined
}
describe("PlanEditor completion keys", () => {
it("opens the popup as the author types a key prefix", async () => {
const wrapper = mountEditor()
await type(wrapper, "t")
expect(wrapper.find(".completion").exists()).toBe(true)
expect(wrapper.findAll(".completion .label").map((el) => el.text())).toContain("title")
})
it("Tab accepts the selected item without navigating first", async () => {
const wrapper = mountEditor()
await type(wrapper, "t")
await wrapper.find("textarea").trigger("keydown", { key: "Tab" })
expect(lastEmit(wrapper)).toBe("title = ")
expect(wrapper.find(".completion").exists()).toBe(false)
})
it("Enter accepts the selected item without navigating first", async () => {
const wrapper = mountEditor()
await type(wrapper, "t")
await wrapper.find("textarea").trigger("keydown", { key: "Enter" })
expect(lastEmit(wrapper)).toBe("title = ")
expect(wrapper.find(".completion").exists()).toBe(false)
})
it("Escape dismisses the popup so the next Enter is a newline, not an accept", async () => {
const wrapper = mountEditor()
await type(wrapper, "t")
await wrapper.find("textarea").trigger("keydown", { key: "Escape" })
expect(wrapper.find(".completion").exists()).toBe(false)
await wrapper.find("textarea").trigger("keydown", { key: "Enter" })
// Popup is gone, so Enter accepts nothing — the value stays as typed.
expect(lastEmit(wrapper)).toBe("t")
})
})

View File

@@ -2,12 +2,14 @@
import { ref, computed, onMounted, nextTick } from "vue" import { ref, computed, onMounted, nextTick } from "vue"
import type { HighlighterCore } from "shiki/core" import type { HighlighterCore } from "shiki/core"
import { getCompletions, type CompletionContext } from "./completion" import { getCompletions, type CompletionContext } from "./completion"
import { fitPopup } from "./popupFit"
const props = defineProps<{ modelValue: string; error: string | null }>() const props = defineProps<{ modelValue: string; error: string | null }>()
const emit = defineEmits<{ "update:modelValue": [value: string] }>() const emit = defineEmits<{ "update:modelValue": [value: string] }>()
const textarea = ref<HTMLTextAreaElement>() const textarea = ref<HTMLTextAreaElement>()
const backdrop = ref<HTMLElement>() const backdrop = ref<HTMLElement>()
const popupEl = ref<HTMLElement>()
const highlighter = ref<HighlighterCore>() const highlighter = ref<HighlighterCore>()
// ── Completion popup state ─────────────────────────────────────────────────── // ── Completion popup state ───────────────────────────────────────────────────
@@ -70,10 +72,29 @@ function position() {
const line = before.slice(before.lastIndexOf("\n") + 1) const line = before.slice(before.lastIndexOf("\n") + 1)
let col = 0 let col = 0
for (const ch of line) col = ch === "\t" ? col + (2 - (col % 2)) : col + 1 for (const ch of line) col = ch === "\t" ? col + (2 - (col % 2)) : col + 1
popup.value = { const caretLeft = metrics.padLeft + col * metrics.charWidth - el.scrollLeft
left: metrics.padLeft + col * metrics.charWidth - el.scrollLeft, const caretTop = metrics.padTop + lineIndex * metrics.lineHeight - el.scrollTop
top: metrics.padTop + (lineIndex + 1) * metrics.lineHeight - el.scrollTop, popup.value = { left: caretLeft, top: caretTop + metrics.lineHeight }
// The popup has no measurable size until Vue has rendered it; adjust once it does.
nextTick(() => keepInView(caretLeft, caretTop))
} }
/** Nudge the popup back inside the window once it has a measurable size. */
function keepInView(caretLeft: number, caretTop: number) {
const el = textarea.value
const list = popupEl.value
if (!el || !list) return
// popup left/top are relative to `.code`, which the textarea fills exactly, so
// the textarea rect bridges those local coordinates to the window.
const ta = el.getBoundingClientRect()
const box = list.getBoundingClientRect()
popup.value = fitPopup({
caret: { left: caretLeft, top: caretTop },
lineHeight: metrics.lineHeight,
box: { width: box.width, height: box.height },
origin: { left: ta.left, top: ta.top },
viewport: { width: window.innerWidth, height: window.innerHeight },
})
} }
function accept(i: number) { function accept(i: number) {
@@ -115,6 +136,8 @@ function onKeydown(e: KeyboardEvent) {
break break
case "Enter": case "Enter":
case "Tab": case "Tab":
// Both accept the highlighted item whenever the popup is open. To insert a
// newline instead, dismiss the popup with Escape first, then press Enter.
e.preventDefault() e.preventDefault()
accept(selected.value) accept(selected.value)
break break
@@ -184,6 +207,7 @@ function syncScroll() {
></textarea> ></textarea>
<ul <ul
v-if="completion" v-if="completion"
ref="popupEl"
class="completion" class="completion"
:style="{ left: popup.left + 'px', top: popup.top + 'px' }" :style="{ left: popup.left + 'px', top: popup.top + 'px' }"
> >

View File

@@ -14,11 +14,12 @@ function labelsAt(marked: string): string[] {
} }
describe("completion context", () => { describe("completion context", () => {
it("offers plan keys and block headers on an empty top-level line", () => { it("offers plan keys on an empty top-level line", () => {
expect(labelsAt("|")).toEqual(["title", "start", "end", "[[feature]]", "[[milestone]]"]) // Headers wait for a blank line above; at the very top there's nothing above.
expect(labelsAt("|")).toEqual(["title", "start", "end"])
}) })
it("offers feature keys inside a [[feature]] block", () => { it("offers feature keys inside a [[feature]] block, no headers on the first line", () => {
expect(labelsAt("[[feature]]\n|")).toEqual([ expect(labelsAt("[[feature]]\n|")).toEqual([
"name", "name",
"start", "start",
@@ -28,14 +29,27 @@ describe("completion context", () => {
"status", "status",
"learning", "learning",
"note", "note",
"[[feature]]",
"[[milestone]]",
]) ])
}) })
it("offers milestone keys inside a [[milestone]] block", () => { it("offers milestone keys inside a [[milestone]] block, no headers on the first line", () => {
expect(labelsAt("[[milestone]]\n|")).toEqual([ expect(labelsAt("[[milestone]]\n|")).toEqual(["name", "week", "requires"])
"name", })
it("keeps offering remaining keys on a single new line, still without headers", () => {
expect(labelsAt('[[milestone]]\nname = "M"\n|')).toEqual(["week", "requires"])
})
it("volunteers block headers only after a blank line (two new lines)", () => {
const filled = '[[milestone]]\nname = "M"\nweek = 2026-01-01\nrequires = []'
// Directly under the last property: nothing left to add ⇒ no popup.
expect(getCompletions(...atCaret(`${filled}\n|`))).toBeNull()
// A blank line signals a new block is wanted ⇒ offer the headers.
expect(labelsAt(`${filled}\n\n|`)).toEqual(["[[feature]]", "[[milestone]]"])
})
it("volunteers headers after a blank line even with keys still open", () => {
expect(labelsAt('[[milestone]]\nname = "M"\n\n|')).toEqual([
"week", "week",
"requires", "requires",
"[[feature]]", "[[feature]]",
@@ -143,3 +157,25 @@ describe("completion replace range", () => {
expect(ctx.items[0].insert).toBe("[[feature]]") expect(ctx.items[0].insert).toBe("[[feature]]")
}) })
}) })
describe("bracket header block separation", () => {
/** The inserts offered at the `|` marker. */
const insertsAt = (marked: string) => {
const caret = marked.indexOf("|")
const source = marked.slice(0, caret) + marked.slice(caret + 1)
return getCompletions(source, caret)!.items.map((i) => i.insert)
}
it("prepends a blank line when a bare [ glues onto the block above", () => {
// No blank line above ⇒ accepting must not weld the new block to the old one.
expect(insertsAt('[[feature]]\nname = "X"\n[|')).toEqual(["\n[[feature]]", "\n[[milestone]]"])
})
it("adds no leading newline when a blank line already separates the block", () => {
expect(insertsAt('[[feature]]\nname = "X"\n\n[|')).toEqual(["[[feature]]", "[[milestone]]"])
})
it("adds no leading newline at the very top of the document", () => {
expect(insertsAt("[|")).toEqual(["[[feature]]", "[[milestone]]"])
})
})

View File

@@ -103,12 +103,21 @@ export function getCompletions(
if (key) { if (key) {
const [, indent, brackets, word] = key const [, indent, brackets, word] = key
if (brackets) { if (brackets) {
return result(lineStart + indent.length, caret, filter(HEADERS, word)) return result(lineStart + indent.length, caret, filter(headerItems(source, lineStart), word))
} }
const keys = const keys =
block === "feature" ? FEATURE_KEYS : block === "milestone" ? MILESTONE_KEYS : PLAN_KEYS block === "feature" ? FEATURE_KEYS : block === "milestone" ? MILESTONE_KEYS : PLAN_KEYS
const taken = presentKeys(source, lineStart, block) const taken = presentKeys(source, lineStart, block)
const items = filter([...keys.filter((k) => !taken.has(k.label)), ...HEADERS], word) const remaining = keys.filter((k) => !taken.has(k.label))
// Volunteer a new block header only once there's a blank line above: a new
// [[…]] wants a gap before it, so offering one right under the last property
// (or straight after the header line) pops the menu up too eagerly. Typing a
// bare `[` still summons headers anywhere (handled above). With all keys set
// and no gap, `remaining` is empty ⇒ result() returns null ⇒ no popup.
const items = filter(
blankLineAbove(source, lineStart) ? [...remaining, ...HEADERS] : remaining,
word,
)
return result(caret - word.length, caret, items) return result(caret - word.length, caret, items)
} }
@@ -119,6 +128,23 @@ function result(from: number, to: number, items: Completion[]): CompletionContex
return items.length ? { from, to, items } : null return items.length ? { from, to, items } : null
} }
/** Header completions for a bare `[`. When the block above isn't already set off
* by a blank line, the insert carries a leading newline so accepting `[[…]]`
* never glues the new block onto the previous one. (At the top of the document
* there's nothing to separate, so no gap is added.) */
function headerItems(source: string, lineStart: number): Completion[] {
if (lineStart === 0 || blankLineAbove(source, lineStart)) return HEADERS
return HEADERS.map((h) => ({ ...h, insert: "\n" + h.insert }))
}
/** Is the line directly above the caret's line blank (empty or whitespace only)?
* Gates header suggestions to the "two new lines" rule — see the key branch. */
function blankLineAbove(source: string, lineStart: number): boolean {
if (lineStart === 0) return false // top of the document — nothing above
const prevStart = source.lastIndexOf("\n", lineStart - 2) + 1
return /^[ \t]*$/.test(source.slice(prevStart, lineStart - 1))
}
/** Today as yyyy-mm-dd in the author's local zone — the calendar day they see, /** Today as yyyy-mm-dd in the author's local zone — the calendar day they see,
* not UTC's (which slips a day near midnight). Injectable keeps tests stable. */ * not UTC's (which slips a day near midnight). Injectable keeps tests stable. */
function todayYmd(): string { function todayYmd(): string {

View File

@@ -0,0 +1,54 @@
import { describe, it, expect } from "vitest"
import { fitPopup } from "./popupFit"
// A roomy window with the editor pinned at the top-left, so `origin` adds nothing
// and only the caret/box geometry drives the result.
const base = {
lineHeight: 20,
box: { width: 256, height: 224 },
origin: { left: 0, top: 0 },
viewport: { width: 1000, height: 800 },
margin: 8,
}
describe("fitPopup", () => {
it("anchors just below the caret when it fits", () => {
const { left, top } = fitPopup({ ...base, caret: { left: 100, top: 200 } })
expect(left).toBe(100)
expect(top).toBe(220) // caret.top + lineHeight
})
it("shifts left so the right edge clears the window", () => {
// caret.left 900 + box 256 = 1156, past the 992 usable width by 164.
const { left } = fitPopup({ ...base, caret: { left: 900, top: 200 } })
expect(left).toBe(736) // 900 - 164
expect(left + base.box.width).toBe(base.viewport.width - base.margin)
})
it("flips above the caret line when there's no room below", () => {
// caret near the bottom: below would be 780+224 = 1004, past 792.
const { top } = fitPopup({ ...base, caret: { left: 100, top: 780 } })
expect(top).toBe(780 - 224) // popup bottom now sits on the caret line
})
it("never pushes the flipped popup off the top of the window", () => {
// Tiny window: neither below nor a full flip fits, so clamp to the margin.
const { top } = fitPopup({
...base,
caret: { left: 100, top: 40 },
viewport: { width: 1000, height: 120 },
})
expect(top).toBe(base.margin)
})
it("accounts for the editor's offset within the window (origin)", () => {
// Editor pushed 800px right; a caret at local 100 sits at window x=900, so the
// 256-wide box overflows and must shift left even though caret.left is small.
const { left } = fitPopup({
...base,
caret: { left: 100, top: 200 },
origin: { left: 800, top: 0 },
})
expect(800 + left + base.box.width).toBe(base.viewport.width - base.margin)
})
})

View File

@@ -0,0 +1,33 @@
/** Geometry for keeping the completion popup inside the browser window.
*
* The popup is anchored just below the caret. Near the right or bottom edge of
* the window that anchor spills off-screen and the popup gets clipped, so we:
* • shift it left until its right edge clears the window, and
* • flip it above the caret line when there's no room below.
*
* Coordinates are local to the editor (the textarea's border box); `origin` is
* that box's own top-left in the window, the bridge from local to window space.
*/
export function fitPopup(opts: {
caret: { left: number; top: number }
lineHeight: number
box: { width: number; height: number }
origin: { left: number; top: number }
viewport: { width: number; height: number }
margin?: number
}): { left: number; top: number } {
const { caret, lineHeight, box, origin, viewport } = opts
const margin = opts.margin ?? 8
let left = caret.left
const overRight = origin.left + left + box.width - (viewport.width - margin)
if (overRight > 0) left = Math.max(margin - origin.left, left - overRight)
let top = caret.top + lineHeight
if (origin.top + top + box.height > viewport.height - margin) {
// Not enough room below — flip above, but never off the top of the window.
top = Math.max(margin - origin.top, caret.top - box.height)
}
return { left, top }
}

View File

@@ -1,7 +1,10 @@
// Default Macroplan shown on first load — exercises every state: // Default Macroplan shown on first load — exercises every state:
// on-time delivery, late delivery with slips, in-flight (on-track/at-risk/off-track), // on-time delivery, late delivery with slips, in-flight (on-track/at-risk/off-track),
// an overdue Feature, and a Milestone with unmet required Features. // an overdue Feature, and a Milestone with unmet required Features.
export const SAMPLE_PLAN = `title = "Q3 — Checkout revamp" export const SAMPLE_PLAN = `# Macroplan format version — optional; current is 1. See docs/format.md.
macroplan_version = 1
title = "Q3 — Checkout revamp"
# Optional plan span: pad the plan with lead-in / trailing weeks. # Optional plan span: pad the plan with lead-in / trailing weeks.
# Rule: start ≤ every Feature's start, and end ≥ every Feature's last week. # Rule: start ≤ every Feature's start, and end ≥ every Feature's last week.

View File

@@ -45,6 +45,11 @@ const MilestoneSchema = v.object({
), ),
}) })
/** The Macroplan format version this build understands (see docs/format.md).
* Files may declare `macroplan_version`; a newer version is rejected rather
* than silently mis-rendered against a schema we don't know. */
export const FORMAT_VERSION = 1
/** Parse + validate a Macroplan TOML source into the raw model. */ /** Parse + validate a Macroplan TOML source into the raw model. */
export function parseMacroplan(source: string): RawPlan { export function parseMacroplan(source: string): RawPlan {
let data: Record<string, unknown> let data: Record<string, unknown>
@@ -54,19 +59,51 @@ export function parseMacroplan(source: string): RawPlan {
throw new PlanParseError(e instanceof Error ? e.message : String(e)) throw new PlanParseError(e instanceof Error ? e.message : String(e))
} }
checkVersion(data.macroplan_version)
const features = asBlocks(data.feature, "feature").map((f, i) =>
check(FeatureSchema, f, blockWhere("feature", f, i)),
)
requireUniqueFeatureNames(features)
return { return {
title: data.title != null ? String(data.title) : "Untitled Macroplan", title: data.title != null ? String(data.title) : "Untitled Macroplan",
start: data.start != null ? check(Ymd, data.start, "plan", "start") : undefined, start: data.start != null ? check(Ymd, data.start, "plan", "start") : undefined,
end: data.end != null ? check(Ymd, data.end, "plan", "end") : undefined, end: data.end != null ? check(Ymd, data.end, "plan", "end") : undefined,
features: asBlocks(data.feature, "feature").map((f, i) => features,
check(FeatureSchema, f, blockWhere("feature", f, i)),
),
milestones: asBlocks(data.milestone, "milestone").map((m, i) => milestones: asBlocks(data.milestone, "milestone").map((m, i) =>
check(MilestoneSchema, m, blockWhere("milestone", m, i)), check(MilestoneSchema, m, blockWhere("milestone", m, i)),
), ),
} }
} }
/** Validate the optional `macroplan_version` marker. Absent means the current
* version; a future version we don't understand is rejected rather than
* silently mis-rendered. */
function checkVersion(value: unknown): void {
if (value == null) return
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
throw new PlanParseError("`macroplan_version` must be a positive integer")
}
if (value > FORMAT_VERSION) {
throw new PlanParseError(
`unsupported macroplan_version ${value} — this build understands up to ${FORMAT_VERSION}`,
)
}
}
/** Feature names are the join key for a Milestone's `requires`, so they must be
* unique — otherwise a requirement resolves ambiguously to the first match. */
function requireUniqueFeatureNames(features: readonly { name: string }[]): void {
const seen = new Set<string>()
for (const f of features) {
if (seen.has(f.name)) {
throw new PlanParseError(`duplicate feature name "${f.name}" — feature names must be unique`)
}
seen.add(f.name)
}
}
function asBlocks(value: unknown, key: string): unknown[] { function asBlocks(value: unknown, key: string): unknown[] {
if (value == null) return [] if (value == null) return []
if (!Array.isArray(value)) { if (!Array.isArray(value)) {

View File

@@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest" import { describe, it, expect } from "vitest"
import exampleToml from "../../docs/macroplan.example.toml?raw"
import { parseMacroplan, PlanParseError } from "./parse" import { parseMacroplan, PlanParseError } from "./parse"
import { buildPlan } from "./plan" import { buildPlan } from "./plan"
import { mondayOf, weekRange } from "./week" import { mondayOf, weekRange } from "./week"
@@ -181,3 +182,42 @@ describe("parse validation", () => {
expect(raw.milestones).toHaveLength(3) expect(raw.milestones).toHaveLength(3)
}) })
}) })
describe("format version (macroplan_version)", () => {
const feature = '[[feature]]\nname = "A"\nstart = 2026-06-01\noriginal = 2026-06-08\n'
it("accepts an explicit current version", () => {
expect(() => parseMacroplan(`macroplan_version = 1\n${feature}`)).not.toThrow()
})
it("accepts an absent version (defaults to current)", () => {
expect(() => parseMacroplan(feature)).not.toThrow()
})
it("rejects a future/unsupported version rather than mis-rendering it", () => {
expect(() => parseMacroplan(`macroplan_version = 2\n${feature}`)).toThrow(/unsupported/)
})
it("rejects a non-integer version", () => {
expect(() => parseMacroplan(`macroplan_version = 1.5\n${feature}`)).toThrow(/macroplan_version/)
})
})
describe("feature-name uniqueness", () => {
it("rejects duplicate feature names (they are the milestone join key)", () => {
const dup =
'[[feature]]\nname = "A"\nstart = 2026-06-01\noriginal = 2026-06-08\n' +
'[[feature]]\nname = "A"\nstart = 2026-06-01\noriginal = 2026-06-15\n'
expect(() => parseMacroplan(dup)).toThrow(/duplicate/)
expect(() => parseMacroplan(dup)).toThrow(PlanParseError)
})
})
describe("reference example (docs/macroplan.example.toml)", () => {
it("conforms to the format the parser implements", () => {
const raw = parseMacroplan(exampleToml)
expect(raw.title).toBe("Q3 — Checkout revamp")
expect(raw.features).toHaveLength(5)
expect(raw.milestones).toHaveLength(3)
})
})