port: contacts card (approved mockup, 2/4) #843

Closed
kayg wants to merge 2 commits from port/contacts-card into port/contacts
Owner

Slice 2 of 4 of the Contacts port. Stacked on #841 (base is port/contacts; retarget to dev once #841 merges). Slices 3 (edit fields) and 4 (duplicates + merge) follow.

What it is

The Cardhop-style card (#/contacts/<id>), read-only; nothing here writes to the note.

  • Hero: photo (web/data addresses only; a file path needs the attachments route, so initials show), name, "job title · company".
  • Round actions under the name are real links: tel:, sms:, mailto:, the map. A person with nothing to use has the button dimmed and no href.
  • Fields grouped as the phone groups them (details, phone, email, address, website, dates with the birthday first, social, related), read from the note's card list with contacts/card.ts.
    • Desktop: click copies the line (the shared "Copied" in place); hover shows ⋯ with the line's action and Copy.
    • Phone: tap does the action (a link); a hold (about half a second, barely moving) opens a menu with the action and Copy. A related line that names a contact opens that card.
  • Notes: ## Notes to the end, with [Name](<Name.md>) links that open the note.
  • Timeline: notes that mention the person, linked or by name (notes.backlinks, the same call the backlinks use), newest first, three then "Show all".
  • Family tree: the mockup's layout unchanged (contacts/tree.ts, tested), over the cards the Related lines name two steps out, plus the cards that name this person; the picture flies into the card when a person is tapped.
  • Desktop: sidebar, list and the card in its glass frame; it always has a card open (first person, replace, not stacked). Phone: a third screen that slides over the list; Back returns along the cards you came through, then to the list. The card's ⋯: Pin / Unpin, Open as note.
  • Pure and tested: view.ts (groups, what a tap does, link targets relative to Contacts/, hero links, Notes links), tree.ts.

Decisions

  • Edit is slice 3: this slice keeps the copy gesture instant (the 220 ms wait that lets a double-click edit comes with edit). Long-press shows Copy and the action; Edit joins it in slice 3.
  • Share, "Keep in touch", "On Calternal", pending photo updates, the photo crop and the portraits stand-ins are mockup-only (no backend yet) and are not drawn.
  • The tree needs cards other than the open one: they are read with the existing notes.peek cache (peekNow), at most 30, two steps.

Gates (CI=1 --retries=0, through ~/bin/slot, run on ccfb3a44)

  • bun run check: 0 errors. bunx vitest run (apps/web): 122 passed.
  • e2e/contacts-list.spec.ts + e2e/contacts-card.spec.ts (24 cases each engine pair; 4 skipped are the phone-only / desktop-only ones): chromium-1440 + chromium-390 on dev-01: 20 passed, 4 skipped. webkit-1440 + webkit-390 on dev-02: 20 passed, 4 skipped.
  • Phone layout tested at 390x844 in both engines.

Bundle (brotli, scripts/bundle-size.mjs; base = dev 55f35fcd)

base this PR
first-paint JS 239.5 KB 239.8 KB
first-paint CSS 20.9 KB 20.9 KB
all JS 311.9 KB 325.8 KB
all CSS 35.2 KB 38.6 KB
Contacts chunk (lazy, in "all") - JS 13.7 KB, CSS 3.3 KB

web.bundle.all_js_brotli is 315 KB in perf/budgets.json, so "all JS" is over by 10.8 KB. I did not touch the budget (it needs kayg's approval); the first-paint budgets hold. The chunk is only fetched when #/contacts is opened. If a smaller chunk is wanted, the tree layout (about 5 KB) is the part to split off.

Screenshots (control host, ~/Developer/Worktrees/shots/contacts/)

product- and mockup- pairs at 1440x900 and 390x844: list, card, card-bottom, and sidebar (phone). Seeded with the same kind of data (the mockup uses its own fixtures, so names differ).

Not ported yet

Portraits and the photo from a file in the Home; "My card" marker/Upcoming/Tidy up rows (need bodies for every row); the desktop arrow-key walk of the list; Share; Keep in touch; On Calternal.


Round 2 (after kayg's side-by-side and the decisions file)

Checked against calternal-decisions-contacts.md: the format and the rest hold (a contact is Contacts/<Name>.md; Lists are tags; ## Notes to the end is the phone's Notes; ISO dates; Pinned under the Me card; "opening a card doesn't count" for Last mentioned; U1 tab order). The note format has no marker for "your own card" (frontmatter is only format, calternal-id, tags), so the account-name match stays. Gaps it showed: the Message action offered only sms:; the decisions want WhatsApp, FaceTime/iMessage and Telegram on by default from the number (not done yet, listed below).

Changed in this PR since the first report:

  • Row names 16 px / weight 500, avatar 40 px (44 on a phone), as the mockup. The Contacts tab and "All Contacts" use the mockup's contact-card icon (c-contacts, in the shared Icon).
  • No divider between rows of a group; a line with no label is named for its kind ("website").
  • Facts index, lazily: the cards' notes are read in batches of fifty (notes.peek) after the list shows, and a card again only when its note changed. It feeds: Upcoming (birthdays and anniversaries within 7 days, under the Me card, from the note's ISO dates), the company building on a company's picture, search over nickname/company/job title/emails/numbers, and the grey second line that tells two people with one name apart (town, else number).
  • Last mentioned under the job line (newest note that names them, from the backlinks already loaded).
  • Desktop arrow-key walk of the list (↓ ↑, one history entry per walk); the desktop now opens the first person as the list shows them (A to Z), not in index order (a bug in the first version).
  • Family tree is a chunk of its own, fetched only when the card scrolls near its place (and the family cards are read then). The Share pill is omitted: it cannot be inert without being fake.
  • Bundle: see #849 (the stack top). First-paint JS 239.99 KB (budget 240).
Slice 2 of 4 of the Contacts port. **Stacked on #841** (base is `port/contacts`; retarget to `dev` once #841 merges). Slices 3 (edit fields) and 4 (duplicates + merge) follow. ## What it is The Cardhop-style card (`#/contacts/<id>`), read-only; nothing here writes to the note. - **Hero**: photo (web/data addresses only; a file path needs the attachments route, so initials show), name, "job title · company". - **Round actions** under the name are real links: `tel:`, `sms:`, `mailto:`, the map. A person with nothing to use has the button dimmed and no `href`. - **Fields grouped as the phone groups them** (details, phone, email, address, website, dates with the birthday first, social, related), read from the note's card list with `contacts/card.ts`. - Desktop: click copies the line (the shared "Copied" in place); hover shows ⋯ with the line's action and Copy. - Phone: tap does the action (a link); a hold (about half a second, barely moving) opens a menu with the action and Copy. A related line that names a contact opens that card. - **Notes**: `## Notes` to the end, with `[Name](<Name.md>)` links that open the note. - **Timeline**: notes that mention the person, linked or by name (`notes.backlinks`, the same call the backlinks use), newest first, three then "Show all". - **Family tree**: the mockup's layout unchanged (`contacts/tree.ts`, tested), over the cards the Related lines name two steps out, plus the cards that name this person; the picture flies into the card when a person is tapped. - Desktop: sidebar, list and the card in its glass frame; it always has a card open (first person, replace, not stacked). Phone: a third screen that slides over the list; Back returns along the cards you came through, then to the list. The card's ⋯: Pin / Unpin, Open as note. - Pure and tested: `view.ts` (groups, what a tap does, link targets relative to `Contacts/`, hero links, Notes links), `tree.ts`. ## Decisions - Edit is slice 3: this slice keeps the copy gesture instant (the 220 ms wait that lets a double-click edit comes with edit). Long-press shows Copy and the action; Edit joins it in slice 3. - Share, "Keep in touch", "On Calternal", pending photo updates, the photo crop and the portraits stand-ins are mockup-only (no backend yet) and are not drawn. - The tree needs cards other than the open one: they are read with the existing `notes.peek` cache (`peekNow`), at most 30, two steps. ## Gates (CI=1 --retries=0, through ~/bin/slot, run on `ccfb3a44`) - `bun run check`: 0 errors. `bunx vitest run` (apps/web): 122 passed. - `e2e/contacts-list.spec.ts` + `e2e/contacts-card.spec.ts` (24 cases each engine pair; 4 skipped are the phone-only / desktop-only ones): chromium-1440 + chromium-390 on dev-01: 20 passed, 4 skipped. webkit-1440 + webkit-390 on dev-02: 20 passed, 4 skipped. - Phone layout tested at 390x844 in both engines. ## Bundle (brotli, `scripts/bundle-size.mjs`; base = dev `55f35fcd`) | | base | this PR | |---|---|---| | first-paint JS | 239.5 KB | 239.8 KB | | first-paint CSS | 20.9 KB | 20.9 KB | | all JS | 311.9 KB | 325.8 KB | | all CSS | 35.2 KB | 38.6 KB | | Contacts chunk (lazy, in "all") | - | JS 13.7 KB, CSS 3.3 KB | **`web.bundle.all_js_brotli` is 315 KB in perf/budgets.json, so "all JS" is over by 10.8 KB.** I did not touch the budget (it needs kayg's approval); the first-paint budgets hold. The chunk is only fetched when `#/contacts` is opened. If a smaller chunk is wanted, the tree layout (about 5 KB) is the part to split off. ## Screenshots (control host, `~/Developer/Worktrees/shots/contacts/`) `product-` and `mockup-` pairs at 1440x900 and 390x844: `list`, `card`, `card-bottom`, and `sidebar` (phone). Seeded with the same kind of data (the mockup uses its own fixtures, so names differ). ## Not ported yet Portraits and the photo from a file in the Home; "My card" marker/Upcoming/Tidy up rows (need bodies for every row); the desktop arrow-key walk of the list; Share; Keep in touch; On Calternal. --- ## Round 2 (after kayg's side-by-side and the decisions file) Checked against `calternal-decisions-contacts.md`: the format and the rest hold (a contact is `Contacts/<Name>.md`; Lists are tags; `## Notes` to the end is the phone's Notes; ISO dates; Pinned under the Me card; "opening a card doesn't count" for Last mentioned; U1 tab order). The note format has no marker for "your own card" (frontmatter is only `format`, `calternal-id`, `tags`), so the account-name match stays. Gaps it showed: the Message action offered only `sms:`; the decisions want WhatsApp, FaceTime/iMessage and Telegram on by default from the number (not done yet, listed below). Changed in this PR since the first report: - Row names 16 px / weight 500, avatar 40 px (44 on a phone), as the mockup. The Contacts tab and "All Contacts" use the mockup's contact-card icon (`c-contacts`, in the shared `Icon`). - No divider between rows of a group; a line with no label is named for its kind ("website"). - **Facts index**, lazily: the cards' notes are read in batches of fifty (`notes.peek`) after the list shows, and a card again only when its note changed. It feeds: **Upcoming** (birthdays and anniversaries within 7 days, under the Me card, from the note's ISO dates), the company building on a company's picture, search over nickname/company/job title/emails/numbers, and the grey second line that tells two people with one name apart (town, else number). - **Last mentioned <date>** under the job line (newest note that names them, from the backlinks already loaded). - **Desktop arrow-key walk** of the list (↓ ↑, one history entry per walk); the desktop now opens the first person *as the list shows them* (A to Z), not in index order (a bug in the first version). - **Family tree is a chunk of its own**, fetched only when the card scrolls near its place (and the family cards are read then). The Share pill is **omitted**: it cannot be inert without being fake. - Bundle: see #849 (the stack top). First-paint JS 239.99 KB (budget 240).
Photo or initials, name and what they do, the round actions as links (tel:, sms:, mailto:, the map), the fields grouped as the phone
groups them (click copies on a desktop, tap does the action and a hold lists Copy on a phone), the Notes with their links, the
timeline of notes that mention the person (notes.backlinks) and the family tree (the mockup's layout, over the cards the Related
lines name). The card view model and the tree are pure and unit tested.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Contacts: no svelte warnings from the card
Some checks failed
CI / server (pull_request) Has been cancelled
CI / rust (pull_request) Has been cancelled
CI / web (pull_request) Has been cancelled
Markdown extraction contract / markdown-native-contract (pull_request) Has been cancelled
Markdown extraction contract / markdown-editor-contract (pull_request) Has been cancelled
ccfb3a44b4
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Author
Owner

First-paint diet (BM15, #947). Head a80a3bb0 (unchanged by me).

First-paint bytes are brotli q11 from apps/web/scripts/bundle-size.mjs (JS of the first-paint closure = the index chunk; CSS of the first stylesheet). Dev baseline 41f2efa3: 238,797 B JS, 19,250 B CSS. A second figure, "hash-normalised", is the same chunk with the 8-character chunk-file hashes replaced by a fixed string: the hashes are random, and they move the real brotli number by ±300 B between two builds of identical code, so a few-hundred-byte claim is only meaningful in the normalised figure (dev: 238,804). Chunk budgets: every new lazy chunk's line is taken from the first-script line or the idle-screens line so the total stays 404.2; perf/web.sh --bundle-only --gate passes on this head.

first JS first CSS lazy JS
#841 head 239,152 19,250 128,409
this PR 239,205 (+53 over #841, +408 over dev; normalised 239,038 = 0 over #841, +234 over dev) 19,250 (0) 140,091 (+17.5 KB lazy, +3.7 KB CSS)

Nothing here adds to first paint beyond one icon path in Icon.svelte (about +150 raw). contacts-card + contacts-list specs: 46 passed, 10 skipped on all four projects.

**First-paint diet (BM15, #947).** Head `a80a3bb0` (unchanged by me). First-paint bytes are brotli q11 from `apps/web/scripts/bundle-size.mjs` (JS of the first-paint closure = the `index` chunk; CSS of the first stylesheet). Dev baseline `41f2efa3`: **238,797 B JS, 19,250 B CSS**. A second figure, "hash-normalised", is the same chunk with the 8-character chunk-file hashes replaced by a fixed string: the hashes are random, and they move the real brotli number by ±300 B between two builds of identical code, so a few-hundred-byte claim is only meaningful in the normalised figure (dev: 238,804). Chunk budgets: every new lazy chunk's line is taken from the first-script line or the idle-screens line so the total stays 404.2; `perf/web.sh --bundle-only --gate` passes on this head. | | first JS | first CSS | lazy JS | |---|---|---|---| | #841 head | 239,152 | 19,250 | 128,409 | | this PR | 239,205 (**+53 over #841**, +408 over dev; normalised 239,038 = **0 over #841**, +234 over dev) | 19,250 (0) | 140,091 (+17.5 KB lazy, +3.7 KB CSS) | Nothing here adds to first paint beyond one icon path in `Icon.svelte` (about +150 raw). `contacts-card` + `contacts-list` specs: **46 passed, 10 skipped** on all four projects.
Author
Owner

#843 per-PR gates at a5aea510: BLOCKED on two non-spec gates

Gated as a stack: #841 63faf541 + this PR (7761f4d2 + merges of dev and #841), no conflicts. Numbers are for the stack, i.e. include #841.

gate result
lint / svelte-check / vitest / ui primitives pass / 2 errors, 16 warnings = dev's / pass
Chromium + WebKit 1440+390: contacts-card, contacts-list, phone-foot Chromium 27 passed, 9 skipped, 0 failed; WebKit 27 passed, 9 skipped, 0 failed
perf/web.sh --bundle-only --gate rc 0 against its budgets, but first-paint JS brotli 233.095 KB vs dev 232.862 (+233 B); first CSS 18.571 (=) vs dev 18.571
check-ui-primitives (from #941, run on this tree) NEW finding vs dev: contacts/ContactsScreen.svelte:90, contacts/ContactTop.svelte (hand-made glass pill: use <Pill alone>); the fix needs #941's Pill, so it follows #941 into dev
Rust perf n/a

To go GREEN: (1) cut ≥ 233 B from the first script or get an explicit OK; (2) move the pill to <Pill alone> once #941 is in dev.

## #843 per-PR gates at `a5aea510`: BLOCKED on two non-spec gates Gated as a stack: #841 `63faf541` + this PR (`7761f4d2` + merges of dev and #841), no conflicts. Numbers are for the stack, i.e. include #841. | gate | result | |---|---| | lint / svelte-check / vitest / ui primitives | pass / 2 errors, 16 warnings = dev's / pass | | Chromium + WebKit 1440+390: contacts-card, contacts-list, phone-foot | Chromium 27 passed, 9 skipped, 0 failed; WebKit 27 passed, 9 skipped, 0 failed | | `perf/web.sh --bundle-only --gate` | rc 0 against its budgets, **but first-paint JS brotli 233.095 KB vs dev 232.862 (+233 B)**; first CSS 18.571 (=) vs dev 18.571 | | `check-ui-primitives` (from #941, run on this tree) | **NEW finding vs dev: `contacts/ContactsScreen.svelte:90`, `contacts/ContactTop.svelte`** (hand-made glass pill: use `<Pill alone>`); the fix needs #941's Pill, so it follows #941 into dev | | Rust perf | n/a | To go GREEN: (1) cut ≥ 233 B from the first script or get an explicit OK; (2) move the pill to `<Pill alone>` once #941 is in dev.
Author
Owner

#843 after #941: Pill/press done, specs green, first-paint bytes NOT yet ≤ dev

Head ae5aec765, dev 850c23a9 merged in. Stack on #841 52a7444d. The card's back and ⋯ are <Pill alone> (⋯ passes its element with e.currentTarget, no bind:this); local CSS gone.

gate result
lint / svelte-check / vitest pass / 2 errors, 16 warnings = dev's / pass
check-ui-primitives clean
Chromium (dev-01) + WebKit (dev-02), 1440+390: contacts-card, contacts-list, phone-foot, pill-press all pass (33 passed each, 0 failed)
first-paint JS brotli (dev 232.565 KB, first CSS 17.952 = dev) 232.876: +311 B (stack; +156 B over #841)

Not GREEN until the first-paint bytes are cut by the amount above.

## #843 after #941: Pill/press done, specs green, first-paint bytes NOT yet ≤ dev Head `ae5aec765`, dev `850c23a9` merged in. Stack on #841 `52a7444d`. The card's back and ⋯ are `<Pill alone>` (⋯ passes its element with `e.currentTarget`, no `bind:this`); local CSS gone. | gate | result | |---|---| | lint / svelte-check / vitest | pass / 2 errors, 16 warnings = dev's / pass | | `check-ui-primitives` | **clean** | | Chromium (dev-01) + WebKit (dev-02), 1440+390: contacts-card, contacts-list, phone-foot, pill-press | all pass (33 passed each, 0 failed) | | first-paint JS brotli (dev 232.565 KB, first CSS 17.952 = dev) | **232.876: **+311 B** (stack; +156 B over #841)** | Not GREEN until the first-paint bytes are cut by the amount above.
Author
Owner

#843 re-gated on dev c808ef67: GREEN at 3c329848

Stack on #841 84fd36c1 (merged in) and dev.

gate result
lint / svelte-check / vitest web / ui primitives / check-ui-primitives pass / 0 errors, 16 warnings (= dev) / 109 / 59 / clean
first-paint JS brotli (this PR vs dev c808ef67 225.407 KB vs prod 71827028 233.040 KB) 225.665 KB: +258 B (stack) vs dev, −7.38 KB vs prod (first CSS 17.952 = dev, prod 18.376). Option A: dev after this merge stays ≤ prod.
perf/web.sh --bundle-only --gate rc 0. idle-screens 51.453
Chromium (dev-01) 1440+390: contacts-card, contacts-list, phone-foot, pill-press, preload, lazy-ui 69 passed, 0 failed
WebKit (dev-02) same 69 passed, 0 failed
Rust n/a
## #843 re-gated on dev `c808ef67`: GREEN at `3c329848` Stack on #841 `84fd36c1` (merged in) and dev. | gate | result | |---|---| | lint / svelte-check / vitest web / ui primitives / `check-ui-primitives` | pass / 0 errors, 16 warnings (= dev) / 109 / 59 / clean | | **first-paint JS brotli (this PR vs dev `c808ef67` 225.407 KB vs prod `71827028` 233.040 KB)** | **225.665 KB: +258 B (stack) vs dev, −7.38 KB vs prod** (first CSS 17.952 = dev, prod 18.376). Option A: dev after this merge stays ≤ prod. | | `perf/web.sh --bundle-only --gate` | rc 0. idle-screens 51.453 | | Chromium (dev-01) 1440+390: contacts-card, contacts-list, phone-foot, pill-press, preload, lazy-ui | 69 passed, 0 failed | | WebKit (dev-02) same | 69 passed, 0 failed | | Rust | n/a |
Author
Owner

Merged into dev by the orchestrator (U1 GREEN, credit bank).

Merged into dev by the orchestrator (U1 GREEN, credit bank).
kayg closed this pull request 2026-11-06 11:41:26 +00:00
Some checks failed
CI / server (pull_request) Has been cancelled
CI / rust (pull_request) Has been cancelled
CI / web (pull_request) Has been cancelled
Markdown extraction contract / markdown-native-contract (pull_request) Has been cancelled
Markdown extraction contract / markdown-editor-contract (pull_request) Has been cancelled

Pull request closed

Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kayg/calternal!843
No description provided.