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")
}
}