Commit Graph
16 Commits
Author SHA1 Message Date
rzen d0c546179f Generated and pasted board backgrounds move into .backgrounds/ — the board root stops collecting the app's own pictures
New app-written background images (Theme tab ▸ Pattern, and Edit ▸ Paste
as Board Background) now land in a hidden `.backgrounds/` folder at
board root instead of beside index.md, matching the `.trash/` app-managed
pattern. `background.image` stores the qualified relative reference
(`.backgrounds/facets.png`); the resolver needed no change at all, since
it already accepted any relative path inside the board root — the same
mechanism that already resolved `art/backdrops/sunset.png` resolves the
new location for free. `BoardWriter.writeBoardImage` now creates its
destination folder if missing, since `.backgrounds/` won't exist until a
board's first generated or pasted background.

The Finder collision-ladder (`BoardStore.boardImageName`) is rescoped to
`.backgrounds/`'s own contents, and its overwrite-in-place check now
recognizes only the qualified form as "ours" — a legacy bare
`background.image: facets.png` from before this change is read as a
foreign reference rather than migrated, so a regeneration writes a fresh
`.backgrounds/` file and orphans the old one in place, per the no-migration
ruling. The Theme tab's Pattern/Solid mode-detection was updated to
recognize both the legacy and current spellings as the generator's own
output.

The board loader needed no change: `.backgrounds/` is a hidden,
non-UUID-shaped name, and `.skipsHiddenFiles` already keeps every hidden
entry off the lane walk before any name-based exclusion is consulted —
pinned with a new loader test. Deliberately did not add `.backgrounds` to
IntegrityRules' claimed-name/squatter-displacement table: that table
mirrors a specific existing DESIGN ruling this card doesn't amend.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-09 08:38:07 -04:00
rzen c87616f3fb Comments join attachments on the card face — a quiet bubble-and-count chip, present-only
A card whose thread holds one comment or more now draws a second trailing chip beside the
paperclip: a secondary-tinted bubble glyph plus its count, shown only when the count is above
zero (design ruling 2026-08-09, card e729e30a). Same styling family as the attachments chip —
caption size, secondary tint, decorative and hidden outright from the accessibility tree — but
this one carries a visible count rather than staying icon-only, per the ruling's own "bubble-style
SF Symbol + count." It sits after the attachments chip at the row's trailing edge, in both the
live title row and the drag replica.

The count is a new `Card.commentCount` field the loader fills with a readdir over `comments/`'s
identity-shaped children that carry their own `index.md` — `BoardLoader.commentCount(in:)`, built
on the same `identityShapedChildren` predicate a trash entry's held-card count already uses. Never
a parse: `.draft` and `.trash/` are excluded for free, the same dot-prefixed hidden-entry skip
`CommentThread.load` documents for both, so the walk stays exactly the O(cards) shape
01-storage-format.md § Enhanced schema already commits to. Because the count rides inside the
`card: Card` parameter `CardFaceView` already takes — not a new parameter of its own — drawing the
chip costs nothing beyond a field read on an already-compared value: no new Observable read joins
the body, and the equatable gate already covers it via `Card`'s synthesized `Equatable`.

The one divergence from the comments pane's parsed count is documented rather than hidden: a
comment folder whose `index.md` exists but fails to parse is a `Stray` the thread read excludes by
opening and rejecting it, a cost this readdir does not pay. The face may then read one comment
high until that folder is fixed or removed — the trade the ruling's "cheap directory-entry count…
not a parse" asks for, over paying full parse cost on every card of every load. Every well-formed
comment, and every card with no malformed one, agrees with the pane exactly.

VoiceOver: `AccessibilityPhrases.cardValue` gains a `comments: Int` parameter, appended after
attachments and before the cut-pending phrase — the same left-to-right order the two chips draw
in, so a sighted read and a VoiceOver read never disagree about which comes first. The trashed
lane row's own call site (an opaque unit with no comments to speak of) passes `comments: 0`.

Docs: DESIGN/03-board-ui.md's card-face section describes both chips and retires the stale "closed
with no growth" sentence, honestly recording the 2026-08-09 growth (the hero banner landed hours
earlier, this chip after it) as exposure of facts the card already carries rather than a body
excerpt. DESIGN/10-accessibility.md's flattened-element sentence gains the comment count.
DESIGN/01-storage-format.md's Enhanced schema paragraph records the chip as shipped. WISHLIST #9
is marked shipped in place — not renumbered, since #10 and #11 are cross-referenced elsewhere.

Tests: CardCommentCountListingTests (BoardLoaderTests.swift) pins the readdir against a synthetic
tree — no comments/ folder, an empty one, non-identity-shaped and index-less strays excluded,
.draft/.trash/ excluded for free, agreement with CommentThread.load's parsed count in the
well-formed case, and the one documented divergence on a malformed index.md.
AccessibilityPhrasesTests covers cardValue's new parameter alone, alongside attachments, and
all three fragments together. ViewEquatableTests pins that a comment landing on a card is a gate
difference. BoardRenderPerformanceTests adds a render-cost guard: one comment added to one card
on a hosted 180-card board re-renders a handful of bodies, not the board.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-09 01:21:33 -04:00
rzen ce92c24190 Hero image for cards — one of the card's own attachments, banded across its face
A card whose `hero:` names one of its own attachments draws that picture as a
banner across the full width of its plate, above the icon-and-title row,
aspect-fill cropped into a fixed 2.75 em band — 36pt at the standard body, and
em-scaled like every other figure the board draws, so it grows with the system
text size and with the board's zoom rather than shrinking against a title twice
its usual size. The figure sits deliberately under the 44pt a plain one-line
card is tall: a hero card should read as a card with a picture on it rather than
a picture with a caption, which is 03's standing rule that the title dominates.

The key's grammar is a **bare filename**, and that is what separates it from the
board background's `image` subkey rather than a nervousness about paths. A board
names a file anywhere under its root, so a path is that key's reading and where
it leads is the renderer's question. A card names one of the files it already
owns — the flat `attachments/` folder the app lists, relocates into, and carries
through every move, copy, trash and restore — so `hero: art/sketch.png` is not an
awkward spelling of a hero image, it is a value the key cannot mean. It therefore
has no reading at all: a value carrying a separator, or spelling `.`/`..`, or
empty, is malformed at the document layer, which renders it as absent and leaves
the coerce tier's trace, exactly as `width: 1.5` does. The bytes stay as written,
the resolver re-checks containment anyway, and the whole degrade family below
that — a name pointing at a missing file, an unreadable one, or one that is not
an image — ends the same way: no banner, no defect, nothing written.

That last promise is about *height* as much as about ink, so the band is given no
height at all until a picture has actually decoded. A card whose hero cannot be
drawn lays out identically to a card with no key, structurally rather than by a
branch somebody has to remember; the price is one settle per hero as a board
opens, and none after that. Everything else the face draws is attached outside
the new stack and is untouched by it — the accent stripe still runs the plate's
full leading edge across the band's corner, the selection and file-hover strokes
still ring the whole plate, the cut and drag dims still cover it, and the drop
model still registers the plate's real height, so a hero card is simply a taller
card the masonry already understands. The trash draws it too, by the one-face
rule.

Decoding is ImageIO's downsampling path off the main actor at a quarter of the
backdrop's pixel budget (`BoardBackdrop.decode` gained the limit as a parameter
rather than being copied), and the results live in one app-wide, deliberately
non-observable cache keyed on path plus the file's date and size. Non-observable
because a tracked write there would invalidate every hero face on the board,
which is the O(board) invalidation this view was rebuilt once already to shed;
each face holds its own picture in view state and seeds it from the cache, which
is also what lets the drag replica — whose preview builder is non-escaping and
cannot await anything — carry the band at the face's real height. Taking a stamp
twice from one URL value turned out to answer with the first read's date and size
however many times the bytes had changed, so `stamp(of:)` now drops its cached
resource values first; noticing a replacement is the only thing a stamp is for.

The face takes the resolved URL as a compared input rather than resolving it, for
selected-ness's reason one axis over: resolving needs the card's folder, which a
face does not know, and finding it from the snapshot would be a board walk per
face. The lane and the trash column each know their own container and compute it
once for the whole strip.

There is no in-app setter this version — the key is written by hand or by an
agent, which is why the guide bumps to v13 with a clause spelling the grammar out
beside the other card keys, and why `attachments/` gets the one-line pointer an
agent that has just written `![](attachments/x.png)` will need. "Set as Hero"
from the attachment row is future work, as is the card window and print, which
draw the same model and show no banner today.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-08 23:41:15 -04:00
rzen 988a7245a3 Memoize the reload parse and short-circuit value-equal snapshots
The loader gains a ParseMemo — the previous walk's parsed documents keyed
by root-relative path, trusted on the git-index heuristic (mtime + size,
no hashing) and passed as an input so the loader stays stateless. A hit
skips exactly one file read; schema, order, coercions, dedupe, and every
directory listing run fresh, so memoized and cold walks are output-
identical (golden-corpus equivalence suite). Entries record only past the
schema gate, so a defect can never be answered from the memo.

The store skips the snapshot assignment wholesale when the fresh model is
value-equal — no @Observable churn, no render pass, no snapshotGeneration
bump — and a new landedReloads counter carries walk-completion for the
three consumers whose subject is the walk, not the snapshot: the card
window's comment thread, the comment search index, and the auto-committer's
covering gate (which now counts a completed walk as covering even when
nothing changed). Warnings and defects move on their own equality; failed
reloads bump neither counter. An injectable ParseCounter makes the
single-file-echo claim a test.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-08-01 11:38:25 -04:00
rzen ba1726fa77 The loader collects every fail-fast defect and honors per-open skips
Phase 1 of the decision surface (01 ▸ Malformed input, settled
2026-07-31): BoardLoadFailure aggregates the walk's defects in walk
order — stop-at-first retires. Environmental failures (unreadable root,
not-a-directory) stay immediate single-defect throws: there is no walk
to collect from. A defective root index is recorded and the walk
continues into the children (nothing in the walk consults the parsed
root document — verified); a defective lane, card, or trash-entry index
records and skips its subtree, Re-check's whole-walk re-aggregation
being the designed loop for what hides beneath. load(skipping:) is the
per-open skip channel: a skipped path's item is omitted from the model
and surfaces as LoadWarning.userSkipped; root paths are unskippable by
construction. The reload-breakage banner carries the aggregate ("…and
N more"), single-defect sentences byte-identical to before. Two new
multi-defect fixture boards; suite 2591 green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-08-01 09:12:49 -04:00
rzen 274ccd9ff5 Realign code with the 2026-07-31 findings-resolution rulings
The full bullet list from Implementation card bf080d9a — both ruling
batches, including the three appended mid-session by 16ef377:

- Restore subjects compose the inverse, never nest: crossing "Undo: S"
  emits "Redo: S" and vice versa; parity, not stack depth, reads a
  legacy double prefix (GitHistoryProvider.restoreSubject).
- Git-operation failures join the one-shot failure banner tier:
  BannerCenter.GitFailureBanner (undo/redo/branchSwitch/addGit), error
  tone at failure rank merged with write one-shots by recency; the
  postLoss compromise is retired at both AppModel wirings.
- order/schema optional below the board root: append-at-end reading
  (ordered siblings first, folder-name tie-break among the order-less),
  schema reads 1, both coerce-tier logged; the root keeps its
  requirements. Ranks.resolvedOrders materializes finite ranks so
  models and placement math stay untouched; first Writer rewrite
  stamps a real rank on touch, placement against an order-less sibling
  stamps that sibling inline in the same bracket. Agent guide v10
  teaches optional keys and zero-read filing. Hostile-YAML order
  shapes become coercion tests; Fixtures/Valid/optional-keys.kanban
  replaces the four retired Malformed boards.
- .gitignore is the relocation-heal noise gate: GitignoreRules pure
  matcher (standard semantics, board-root file only), loader consults
  it once per walk so matched loose files keep the stray posture;
  seeded (.DS_Store + .*.lanework-*) at board creation and template
  instantiation, healed in when missing at open — repo-nested
  included; empty file honored, existing files never edited; the
  committer's obedience via libgit2 status is pinned by test.
- Comments crash-residue sweep gates on step ownership: HistoryStep
  derives backing from its own undo expectations, backedContent unions
  both stacks, the sweep purges per-entry only what no live step owns.
- Skip-purge decoupled (16ef377): a stale-skipped coarse step strands
  whole in NativeHistoryProvider.strandedSteps — still backing, retired
  only at session end; clean exits purge as before.
- Coarse close step named "Changes to '<card>'"; the fine body-edit
  wording never leaks onto the board menu.
- Branch-switch settle clears every open card window's fine stack on
  Save All and Discard alike; the empty fold registers no coarse step.
- Close flush awaits its covering snapshot (quiesce + one generation
  bump, 1s bound), and an explicit flush now queues behind an
  in-flight one instead of skipping — the audit-caught interleaving
  could lose a close flush permanently when the debounce fired inside
  the close sequence; regression tests force both races.
- Commit comment bullets sort chronologically by created, not UUID.
- The production-unwired CardBodyEditSession.editSessionDidChange seam
  is deleted with its seam-only tests.
- Composition-root pins: beginSession composes the committer with the
  store's own EchoLedger and binds the announcer (the miswire class).
- Deliberate 06 conformance pass over every 2026-07-31-tagged
  sentence: fixed Change-custom-key subjects (the retired named
  generic was the only producer), the unbuilt Replace attachment
  vocabulary, heal commits now authored Lanework Integrity, the config
  reader scopes identity to plain [user] sections, add-git re-runs
  detection at create (a stale mode-none could initialize inside the
  user's repo), and add-git failures answer at the form or the banner.
  Structural residue filed on the Redesign board.

2554 tests / 439 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-08-01 07:43:45 -04:00
rzen 69084fdff7 Realign code with the 2026-07-29 findings-resolution rulings
Nine rulings land as code. Reorders don't stamp — one container-change
predicate (WriteOperation.rewritesOrderOnly): within-container reorders
and the renumber rescale rewrite only order, while cross-lane, cross-board,
and trash moves stamp modified and clear modified-by; no trash special
case exists, and the m8 undo inverses conform through the same seam.
Copies are transactions: the root-strict/nested-lenient split retires for
a whole-subtree stampability preflight that refuses loudly naming the
offender, and every item-level copy severs remote/remote-state at every
level (whole-board forks carry them verbatim). Paste refuses, never
degrades: the embedded-index.md materialization and its loss row retire;
a missing staged snapshot produces nothing and posts an error-tone
one-shot named from manifest metadata. Coerce-tier fallbacks log through
the Defect stream with path context attached loader-side. Displacement is
level-uniform: a file squatting attachments inside a card heals by the
same rename ladder as board-root squatters; comments stays tolerated.
Delete Immediately joins card and lane context menus as Delete's
⌥-alternate with its own VO custom action, routed through an explicit
container so the menu target outranks standing selection. Agent guide v7
teaches the stamp discipline and the card-level attachments claim, and
sheds two stale v6 lines (lanes trash now; kind is taught). Verified
conformant, unchanged: edition-aware Undo/Redo disable, trash marquee
full-height backdrop.

Both schemes 1854 tests / 318 suites green; verify-editions 30/30.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-30 06:49:11 -04:00
rzen 1020d9fca4 Remove the face carousel — one card presentation
03's resettlement reverses the pathfinder carry-over: the
selection-keyed dual presentation proved undesirable, so a card has one
presentation — selection changes styling, never geometry, and the
masonry never reflows on click. Deleted the carousel view (page dots,
glass underlay, scroll-tick monitor), the QuickLook thumbnail cache
(sole consumer), the pure paging/suppression rules, and the
sole-selected animation key — Motion now keys transactions on the
search query and the drop proposal only. The attachment chip stays as
the face's whole attachment story; viewing media is the card window's
job. No carousel state had leaked beyond the view layer.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-28 08:11:59 -04:00
rzen bea6d02d1d Realign read-side rules — width range coercion, finite order, symlink pins
The design corpus ratified that ranges are part of a sensible reading:
an exact-integer width below 1 now coerces to 1 read-side (bytes
untouched) instead of reading as malformed — the width division must
never see a zero or negative unit — while a non-finite order (.nan,
.inf) is now the same loud malformed-order rejection as a non-numeric
one, guarded at the single point where the double arrives so loader
and Writer inherit it together. The symlink-never-traversed rule
turned out to be already enforced (the loader has filtered symlinks
ahead of the directory check since the first commit); it and the
copy-preserves-the-link-verbatim behavior are now pinned by tests,
alongside the two hostile shapes the corpus names (width: 0,
order: .nan). Five new tests.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-27 14:21:03 -04:00
rzen b4c90838b4 Build card faces with edge-accent styling
The card face becomes real: leading SF Symbol (card default doc.text,
tinted by a valid hand-written iconColor — schema yes, control no),
title or the quiet untitled placeholder, and a quiet paperclip when
the card has attachments — title-only by design, no body excerpt.
Color is the settled K1 edge accent, not a fill: background paints a
4pt stripe down the left edge, resolved through the ported pathfinder
palette (12 icon tints + 12 backgrounds carried over verbatim, plus
raw #RRGGBB[AA]); anything unresolvable paints nothing and stays on
disk exactly as written. The snapshot now carries each card's flat
attachment names — the loader's one read inside a card folder, shared
with the Writer's listing so the m5 carousel and m6 sidebar can never
disagree on order (Finder order, the Writer's existing comparator).
The face keeps its top-aligned structure so the sole-selection
carousel can expand inside the card without moving masonry neighbors.
18 new tests.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-27 13:48:50 -04:00
rzen 23e761120f Give transient UI state an explicit home in the store
TransientBoardState, one per store, holds state by how a reload treats
it: item-referencing sets (selection, drag membership, pending cut)
share one shape and one constraint rule — members must exist in the
current universe — applied in two directions by one primitive, so the
search filter's hidden-cards rule and reload survival are one rule
expressed once; derived state is stored as its inputs only (the query,
never its result set); and the new-card placeholder is a lane-anchored
overlay with no UUID until commit, discarded when its lane vanishes or
tombstones, handed off when the created card's UUID appears. Trash
visibility rides along per-open, never persisted. The decision is
written back into DESIGN/02 § Changes from Kanban — the TBD is closed.

Full suite 352 tests in 64 suites green. Two findings filed.

Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
2026-07-26 21:06:26 -04:00
rzen 4418b7f981 Align the identity predicate with the ratified shape-only rule
Accept liberally, emit conservatively (764a4d4): the gate is 8-4-4-4-12
hex in any case and any UUID version — uuidgen and UUID().uuidString
print uppercase, and a strict lowercase gate would silently stray an
agent's standard-tool card. Identity comparison is UUID-value equality
everywhere: ItemID keeps its byte-faithful rawValue but equates and
hashes on the lowercased canonical form, and the writer's
import-boundary collision check canonicalizes, so a same-UUID arrival
spelled in another case remints instead of slipping past. The app still
mints only lowercase v4 and never renames to canonicalize.

Full suite 342 tests in 63 suites green. Two findings filed.

Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
2026-07-26 20:53:38 -04:00
rzen 19f67bdb78 Build BoardWriter — atomic, minimal-touch writes
The single point through which every mutation becomes a filesystem
operation: read fresh from disk (strict byte-faithful UTF-8), refuse
readable-but-uneditable frontmatter shapes, apply the edit through the
span engine, stamp modified / clear modified-by, then temp-file+rename
atomically. Renumber-visible-children is the sole minimal-touch
exception, tombstones untouched. Structured BoardWriteError carries
operation + path + reason for the future banner surface.

Alongside, three edges the card surfaced:
- The loader now decodes byte-faithfully too, so a BOM'd or non-UTF-8
  index.md is rejected at load per the encoding contract, instead of
  loading via NSString's silent BOM strip and then refusing every write.
- An appended frontmatter line adopts the file's prevailing line ending
  (a CRLF file stays uniformly CRLF when a stamp first lands in it).
- Empty-but-not-blank frontmatter ({}, null, ~) is detected as
  uneditable — appending after it would be unparseable YAML.

30 new unit tests; 176 total green.

Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
2026-07-26 16:49:42 -04:00
rzen 31f7691062 Gate level detection on UUID folder-name shape
Only lowercase-hex 8-4-4-4-12 folder names are lane/card candidates;
anything else is a stray — skipped with a distinct warning, never
descended, never able to fail-fast a load. UUID-shaped folders keep the
prior contract (missing index skips, malformed frontmatter fail-fasts).
Design resolution from the Redesign board. +5 tests.

Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
2026-07-26 16:03:39 -04:00
rzen 8e7c565e07 Adopt read-side coercion for schema-owned fields
Design-review resolutions: schema-owned display fields coerce where a
sensible reading exists (wrong-type scalars read as source text, width
accepts exact-integer strings/doubles) and default where none does;
null reads as missing; duplicate keys last-one-wins and inline-comment
re-splicing recorded in the design (engine change follows). Strict
schema/order fail-fast unchanged. 85 tests green.

Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
2026-07-26 15:47:33 -04:00
rzen 95418a2670 Build BoardLoader — fail-fast fractal tree loading
Pure tree walk: root → lanes → cards, level is position. Fail-fast with
path+reason for bad YAML, missing/malformed schema or order, newer
schema, rootless board; index-less folders skip with a collected+logged
warning; strays, hidden files, and symlinks ignored; board-level
deleted ignored with a warning; tombstones loaded and flagged. 17 tests.

Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
2026-07-26 15:40:46 -04:00