Compare commits

..

3 Commits

Author SHA1 Message Date
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
4 changed files with 56 additions and 11 deletions

View File

@@ -491,7 +491,6 @@ 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.
## 7. Critical performance budget
| Rank | Function | Target | Watched on | If we miss it |

View File

@@ -17,6 +17,10 @@ const popup = ref({ left: 0, top: 0 })
// Suppress the popup for one keystroke after Escape, so it doesn't pop straight
// back open while the author is still typing the word they just dismissed.
let justEscaped = false
// Whether the author has moved the selection since the popup (re)opened. Tab
// always accepts; Enter only accepts once they've engaged with the menu — an
// untouched popup lets Enter do its normal job (a newline) rather than hijack it.
let engaged = false
// Pixel metrics of the (monospace, non-wrapping) textarea, measured once.
let metrics = { charWidth: 8, lineHeight: 20, padLeft: 16, padTop: 14 }
@@ -58,6 +62,7 @@ function refresh() {
if (!el) return
completion.value = justEscaped ? null : getCompletions(el.value, el.selectionStart)
selected.value = 0
engaged = false
if (completion.value) position()
}
@@ -99,6 +104,7 @@ function onKeydown(e: KeyboardEvent) {
if (!ctx) return
const move = (delta: number) => {
e.preventDefault()
engaged = true
selected.value = (selected.value + delta + ctx.items.length) % ctx.items.length
}
// Ctrl+N / Ctrl+P mirror ArrowDown / ArrowUp (readline-style navigation).
@@ -113,11 +119,20 @@ function onKeydown(e: KeyboardEvent) {
case "ArrowUp":
move(-1)
break
case "Enter":
case "Tab":
e.preventDefault()
accept(selected.value)
break
case "Enter":
// Only steal Enter once the author has navigated the menu; otherwise let
// it insert a newline (onInput → refresh reopens the popup if warranted).
if (engaged) {
e.preventDefault()
accept(selected.value)
} else {
completion.value = null
}
break
case "Escape":
e.preventDefault()
completion.value = null

View File

@@ -14,11 +14,12 @@ function labelsAt(marked: string): string[] {
}
describe("completion context", () => {
it("offers plan keys and block headers on an empty top-level line", () => {
expect(labelsAt("|")).toEqual(["title", "start", "end", "[[feature]]", "[[milestone]]"])
it("offers plan keys on an empty top-level line", () => {
// 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([
"name",
"start",
@@ -28,14 +29,27 @@ describe("completion context", () => {
"status",
"learning",
"note",
"[[feature]]",
"[[milestone]]",
])
})
it("offers milestone keys inside a [[milestone]] block", () => {
expect(labelsAt("[[milestone]]\n|")).toEqual([
"name",
it("offers milestone keys inside a [[milestone]] block, no headers on the first line", () => {
expect(labelsAt("[[milestone]]\n|")).toEqual(["name", "week", "requires"])
})
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",
"requires",
"[[feature]]",

View File

@@ -108,7 +108,16 @@ export function getCompletions(
const keys =
block === "feature" ? FEATURE_KEYS : block === "milestone" ? MILESTONE_KEYS : PLAN_KEYS
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)
}
@@ -119,6 +128,14 @@ function result(from: number, to: number, items: Completion[]): CompletionContex
return items.length ? { from, to, items } : null
}
/** 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,
* not UTC's (which slips a day near midnight). Injectable keeps tests stable. */
function todayYmd(): string {