Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions frontend/e2e/atlas-authoring.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
}
})
5 changes: 5 additions & 0 deletions frontend/src/atlas/AtlasStickyNode.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,8 @@
color: var(--fgColor-default);
outline: none;
}

.emptyText {
color: var(--fgColor-muted);
font-style: italic;
}
4 changes: 3 additions & 1 deletion frontend/src/atlas/AtlasStickyNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,9 @@ export const AtlasStickyNode = memo(function AtlasStickyNode({ data }: NodeProps
}
}}
>
<div className={styles.text}>{note?.Text}</div>
<div className={note?.Text ? styles.text : `${styles.text} ${styles.emptyText}`}>
{note?.Text || t('sticky.empty')}
</div>
</div>
)
})
7 changes: 4 additions & 3 deletions frontend/src/atlas/useAtlasCreation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/locales/en/atlas.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@
},
"sticky": {
"placeholder": "Type a note…",
"ariaLabel": "Note"
"ariaLabel": "Note",
"empty": "Empty note"
},
"capture": {
"dropError": "Couldn't read the dropped file."
Expand Down Expand Up @@ -234,4 +235,4 @@
"dissolveBody_other": "Its {{count}} cards move up a level.",
"dissolveConfirm": "Dissolve"
}
}
}
19 changes: 7 additions & 12 deletions internal/domain/atlas/note.go
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
}
12 changes: 9 additions & 3 deletions internal/domain/atlas/note_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 9 additions & 3 deletions internal/services/atlassvc/atlasnote_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}

Expand Down
Loading