diff --git a/frontend/e2e/atlas-authoring.spec.ts b/frontend/e2e/atlas-authoring.spec.ts index d3c3e90f..b380e5a5 100644 --- a/frontend/e2e/atlas-authoring.spec.ts +++ b/frontend/e2e/atlas-authoring.spec.ts @@ -204,3 +204,55 @@ test('atlas creation core: tray, placement popover, right-click create, sticky n rmSync(dir, { recursive: true, force: true }) } }) + +// Regression: an empty note is creatable -- placement itself is the +// capture (the place-then-type flow), and the empty sticky renders a +// muted placeholder until typed into. +// eslint-disable-next-line no-empty-pattern -- this test needs `testInfo` (the second arg), not any fixture. +test('an empty note places, renders its placeholder, and takes text later', async ({}, testInfo) => { + const idx = testInfo.parallelIndex + const dir = mkdtempSync(path.join(tmpdir(), `mill-e2e-atlas-emptynote-${idx}-`)) + const port = ATLAS_AUTHORING_SERVER_BASE_PORT + 40 + idx + const mcpPort = ATLAS_AUTHORING_MCP_BASE_PORT + 40 + idx + let server: SpawnedServer | undefined + const browser = await chromium.launch() + try { + server = await spawnMillServer({ + port, mcpPort, + settingsPath: path.join(dir, 'settings.json'), + executionDbPath: path.join(dir, 'execution.db'), + backupDir: path.join(dir, 'backups'), + }) + const page = await browser.newPage() + await page.goto(`${server.baseURL}/`) + await page.getByRole('link', { name: 'Atlas' }).click() + const board = page.getByTestId('atlas-board') + await expect(board).toBeVisible() + await zoomAllTheWayOut(page) + + await page.keyboard.press('n') + await clickCorner(board, 'top-left') + const draftTextarea = page.getByTestId('atlas-sticky-textarea') + await expect(draftTextarea).toBeVisible() + await draftTextarea.blur() + await expect(draftTextarea).toHaveCount(0) + + const sticky = page.getByTestId('atlas-sticky-note') + await expect(sticky).toHaveCount(1) + await expect(sticky).toContainText('Empty note') + + // Click-select then click-commit enters edit; typed text persists + // and the placeholder leaves. + await sticky.click() + await sticky.click() + const editTextarea = page.getByTestId('atlas-sticky-textarea') + await expect(editTextarea).toBeVisible() + await editTextarea.fill('ZzE2eTypedLater') + await editTextarea.blur() + await expect(sticky).toContainText('ZzE2eTypedLater') + await expect(sticky).not.toContainText('Empty note') + } finally { + await server?.stop() + rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }) + } +}) diff --git a/frontend/src/atlas/AtlasStickyNode.module.css b/frontend/src/atlas/AtlasStickyNode.module.css index ec880e8b..36a83260 100644 --- a/frontend/src/atlas/AtlasStickyNode.module.css +++ b/frontend/src/atlas/AtlasStickyNode.module.css @@ -70,3 +70,8 @@ color: var(--fgColor-default); outline: none; } + +.emptyText { + color: var(--fgColor-muted); + font-style: italic; +} diff --git a/frontend/src/atlas/AtlasStickyNode.tsx b/frontend/src/atlas/AtlasStickyNode.tsx index 97e8ca5c..b2f3d1e5 100644 --- a/frontend/src/atlas/AtlasStickyNode.tsx +++ b/frontend/src/atlas/AtlasStickyNode.tsx @@ -95,7 +95,9 @@ export const AtlasStickyNode = memo(function AtlasStickyNode({ data }: NodeProps } }} > -
{note?.Text}
+
+ {note?.Text || t('sticky.empty')} +
) }) diff --git a/frontend/src/atlas/useAtlasCreation.ts b/frontend/src/atlas/useAtlasCreation.ts index c387c40f..8ff67d78 100644 --- a/frontend/src/atlas/useAtlasCreation.ts +++ b/frontend/src/atlas/useAtlasCreation.ts @@ -252,11 +252,12 @@ export function useAtlasCreation({ parentID, allCards, notes, readOnly, screenTo setDraftNoteFlowPos(null) setDraftNoteParentOverride(null) { - const trimmed = text.trim() - if (pos && trimmed) { + // Empty text still creates: the placement itself is the capture + // (a spatial placeholder typed into later); Escape is the cancel. + if (pos) { const targetParentID = override ?? parentID const position = override ? freeChildPosition(allCardsRef.current, override) : { X: pos.x, Y: pos.y } - void AtlasService.CreateNote(trimmed, position, targetParentID) + void AtlasService.CreateNote(text.trim(), position, targetParentID) .then(() => refreshAtlas()) .catch(console.error) } diff --git a/frontend/src/locales/en/atlas.json b/frontend/src/locales/en/atlas.json index 3f836cdf..d7199dac 100644 --- a/frontend/src/locales/en/atlas.json +++ b/frontend/src/locales/en/atlas.json @@ -82,7 +82,8 @@ }, "sticky": { "placeholder": "Type a note…", - "ariaLabel": "Note" + "ariaLabel": "Note", + "empty": "Empty note" }, "capture": { "dropError": "Couldn't read the dropped file." @@ -234,4 +235,4 @@ "dissolveBody_other": "Its {{count}} cards move up a level.", "dissolveConfirm": "Dissolve" } -} \ No newline at end of file +} diff --git a/internal/domain/atlas/note.go b/internal/domain/atlas/note.go index 6ce2d72c..3f5b3f31 100644 --- a/internal/domain/atlas/note.go +++ b/internal/domain/atlas/note.go @@ -1,10 +1,6 @@ package atlas -import ( - "fmt" - "strings" - "time" -) +import "time" // Note is a quick-capture annotation on the Atlas board (goal 0081 // slice A1's LOCKED design): structurally excluded from every semantic @@ -30,13 +26,12 @@ type Note struct { DeletedAt time.Time } -// ValidateNote checks a Note is well-formed: non-empty text. Whether -// ParentID names an existing card is referential-existence checking, -// left to the service layer like every other domain type in this -// package. +// ValidateNote checks a Note is well-formed. Empty text is LEGAL: a +// note's placement is itself the captured meaning (a spatial +// placeholder typed into later), so deletion is the only removal and +// no text requirement exists. Whether ParentID names an existing card +// is referential-existence checking, left to the service layer like +// every other domain type in this package. func ValidateNote(n Note) error { - if strings.TrimSpace(n.Text) == "" { - return fmt.Errorf("a note needs text") - } return nil } diff --git a/internal/domain/atlas/note_test.go b/internal/domain/atlas/note_test.go index 0feb6713..85392b3e 100644 --- a/internal/domain/atlas/note_test.go +++ b/internal/domain/atlas/note_test.go @@ -2,9 +2,15 @@ package atlas import "testing" -func TestValidateNote_RequiresText(t *testing.T) { - if err := ValidateNote(Note{Text: " "}); err == nil { - t.Error("ValidateNote() on blank text = nil error, want an error") +// Regression: an empty note is LEGAL -- placement itself is the +// captured meaning (a spatial placeholder typed into later); refusing +// blank text blocked the place-then-type flow. +func TestValidateNote_AllowsEmptyText(t *testing.T) { + if err := ValidateNote(Note{Text: " "}); err != nil { + t.Errorf("ValidateNote() on blank text = %v, want nil", err) + } + if err := ValidateNote(Note{Text: ""}); err != nil { + t.Errorf("ValidateNote() on empty text = %v, want nil", err) } if err := ValidateNote(Note{Text: "a thought"}); err != nil { t.Errorf("ValidateNote() on non-blank text = %v, want nil", err) diff --git a/internal/services/atlassvc/atlasnote_test.go b/internal/services/atlassvc/atlasnote_test.go index 9f12f57d..4562d67b 100644 --- a/internal/services/atlassvc/atlasnote_test.go +++ b/internal/services/atlassvc/atlasnote_test.go @@ -36,10 +36,16 @@ func TestCreateNote_UnknownParent_Errors(t *testing.T) { } } -func TestCreateNote_BlankText_Errors(t *testing.T) { +// Regression: empty notes are legal (placement is the capture; the +// place-then-type flow must not be refused). +func TestCreateNote_BlankText_Creates(t *testing.T) { a := newTestAtlasService(t) - if _, err := a.CreateNote(" ", atlas.Position{}, ""); err == nil { - t.Error("CreateNote() with blank text = nil error, want an error") + n, err := a.CreateNote("", atlas.Position{}, "") + if err != nil { + t.Fatalf("CreateNote() with empty text = %v, want nil", err) + } + if n.ID == "" { + t.Error("empty note did not persist") } }