Skip to content

Share Management

The surfaces where stringers grant + revoke shares of jobs to other stringers, where clients (V3) grant + revoke their own shares, and where every actor sees the audit trail of who saw what when. Owned by Mira. Cross-cuts: client-identity-and-sharing (the three rules + the audit log + the notification rules), ADR-0004 (schema + serialization-layer redaction), ADR-0005 (notification kinds + write-on-event hooks), stringer-dashboard (entry point), shared-with-me-inbox (the receiving stringer's surface), design-tokens.

Source requirement

Goal

Make the act of sharing explicit, reversible, and observable:

  1. The stringer doing the sharing knows exactly what they're handing over (which jobs, to whom, with what redaction).
  2. The receiving stringer knows what they got and can refuse it (revoke).
  3. The client knows what was shared about them, and to whom, and when (the audit log surfaces this in V3).
  4. Every grant + revoke is one-click; no confirmation modal that punishes the routine "stringer A is going on holiday and hands her last 10 Lukas-Müller jobs to stringer B" workflow.

Information architecture

Three-section landing page, accessed from the stringer's user menu (Profile → Sharing):

Sharing
├── Grants I've issued  ←  Rule #1 grants this stringer created
├── Grants received     ←  Rules #1/#2/#3 where this stringer is the grantee
└── Audit log (mine)    ←  the share_audit excerpt for grants I'm party to

For V3, a parallel section appears in the client portal:

Sharing  (V3 client portal)
├── My shares — per-job        ←  Rule #2 grants the client created
├── My shares — global         ←  Rule #3 grants the client created
└── Who has seen my data       ←  the share_audit excerpt for me as the subject

V2 ships sections 1 + 2 + 3 (stringer-side). V3 adds the client-side mirror. The audit-log surface is the same data, scoped per actor.

Stage 1 — Grant a Rule #1 share (stringer-initiated)

Entry points: - From the order detail page: a "Share with another stringer" action on a Strung+ order. Pre-fills the order in the picker; the stringer just picks the grantee. - From this Sharing page: an "Issue new grant" action. Empty start; stringer picks orders + grantee from scratch.

The flow has three steps; each step is a distinct screen on sm (mobile-first).

Step 1.1 — Pick orders

┌───────────────────────────────────────┐
│ ←  Issue grant                Cancel  │
├───────────────────────────────────────┤
│                                       │
│  Pick jobs to share                   │  H1
│  Past jobs only.                      │  Subhead (Rule #1 constraint)
│                                       │
│ ┌───────────────────────────────────┐ │
│ │ 🔍 Filter by client               │ │  Search input
│ └───────────────────────────────────┘ │
│                                       │
│  Lukas Müller                         │  Group header (per client)
│ ┌───────────────────────────────────┐ │
│ │ ☐ #2026-0042 · 2026-04-30         │ │  Job row (checkbox)
│ │   Pure Aero 98 · 24/24 kg         │ │
│ └───────────────────────────────────┘ │
│ ┌───────────────────────────────────┐ │
│ │ ☑ #2026-0038 · 2026-04-15         │ │
│ │   Pure Aero 98 · 25/24 kg         │ │
│ └───────────────────────────────────┘ │
│   Share all 7 of Lukas's jobs         │  Bulk shortcut
│                                       │
│  Anna Bauer                           │
│ ┌───────────────────────────────────┐ │
│ │ ☐ #2026-0039 · 2026-04-22         │ │
│ │   Wilson Blade 98 · 25/25 kg      │ │
│ └───────────────────────────────────┘ │
│ ⋯                                     │
│                                       │
├───────────────────────────────────────┤
│   2 jobs selected                     │
│   ┌─────────────────────────────────┐ │
│   │           Continue              │ │  Sticky-bottom
│   └─────────────────────────────────┘ │
└───────────────────────────────────────┘

Behaviour:

  • The list groups Strung+ orders this stringer performed by ClientProfile, sorted alphabetically by client. Within each group, orders sort by strung_at desc.
  • Past-only is enforced by the query (filter: strung_at IS NOT NULL AND deleted_at IS NULL AND stringer_id = :me). Per Rule #1 acceptance criterion, future jobs cannot be granted via Rule #1; the picker simply doesn't surface them.
  • Filter by client is a keyup HTMX swap of the list (hx-trigger="keyup changed delay:200ms").
  • Bulk shortcut ("Share all 7 of Lukas's jobs") appears below each client group and ticks every checkbox in that group at once. Per Rule #1 acceptance criterion, bulk creates one order_shares row per order, revocable individually.
  • Selection counter + Continue CTA sticky-bottom on sm; in the form footer on md+. Continue is disabled until ≥ 1 order is selected.

Step 1.2 — Pick grantee

┌───────────────────────────────────────┐
│ ←  Issue grant                Cancel  │
├───────────────────────────────────────┤
│                                       │
│  Share 2 jobs with                    │  H1 (count from step 1.1)
│                                       │
│ ┌───────────────────────────────────┐ │
│ │ 🔍 Search stringers               │ │  Search input
│ └───────────────────────────────────┘ │
│                                       │
│  Recent grantees                      │  H2
│ ┌───────────────────────────────────┐ │
│ │ ◯ Sarah Reber          @sarah.r   │ │  Stringer row
│ │   Last shared 2026-04-12          │ │
│ └───────────────────────────────────┘ │
│ ┌───────────────────────────────────┐ │
│ │ ◯ Marc Egli            @marc.e    │ │
│ │   Last shared 2026-03-20          │ │
│ └───────────────────────────────────┘ │
│                                       │
│  All stringers                        │  H2
│ ⋯                                     │
│                                       │
├───────────────────────────────────────┤
│   ┌─────────────────────────────────┐ │
│   │           Continue              │ │
│   └─────────────────────────────────┘ │
└───────────────────────────────────────┘

Behaviour:

  • Single-select radio list (one grantee per grant operation; multiple grantees = multiple grant operations).
  • "Recent grantees" lists this stringer's most-recent prior Rule #1 grantees (sorted by latest created_at on order_shares rows where granter_kind=stringer AND granter_stringer_id=:me), capped at 5.
  • "All stringers" lists every other stringer on the platform (alphabetical), paginated.
  • Search filters across display_name, business_name, and @handle (the stringer's identifier — V2 detail; Pax's call whether @handle exists, but stringers are findable somehow).
  • Inviting a non-platform stringer is out of scope per § Out of scope. The picker only shows existing platform stringers; there is no "invite by email" affordance.
  • Continue disabled until a grantee is selected.

Step 1.3 — Confirm

┌───────────────────────────────────────┐
│ ←  Issue grant                Cancel  │
├───────────────────────────────────────┤
│                                       │
│  Confirm                              │  H1
│                                       │
│  You're granting Sarah Reber          │
│  read-only access to:                 │
│                                       │
│ ┌───────────────────────────────────┐ │
│ │ #2026-0038 · 2026-04-15           │ │
│ │ Lukas Müller · Pure Aero 98       │ │
│ └───────────────────────────────────┘ │
│ ┌───────────────────────────────────┐ │
│ │ #2026-0042 · 2026-04-30           │ │
│ │ Lukas Müller · Pure Aero 98       │ │
│ └───────────────────────────────────┘ │
│                                       │
│  What Sarah will see                  │  H2 — redaction surface
│  ✅ Racket, strings, tensions, BYO,   │
│     color, method, dynamic tension    │
│  ✅ Strung / Ordered dates             │
│  ✅ Lukas's first name                 │
│  ❌ Lukas's last name, email, phone   │
│  ❌ Comments on these jobs            │
│  ❌ Pricing (labor, string price,     │
│     subtotal, total)                  │
│                                       │
│  Sarah will be notified in-app.       │  Notification disclosure
│  Lukas will not be notified           │
│  (Rule #1 is a stringer-to-stringer   │
│  grant — see Sharing rules.)          │
│                                       │
│  You can revoke this grant any time   │  Reversibility note
│  from your "Grants I've issued" list. │
│                                       │
├───────────────────────────────────────┤
│   ┌─────────────────────────────────┐ │
│   │       Grant access              │ │  Final CTA
│   └─────────────────────────────────┘ │
└───────────────────────────────────────┘

Behaviour:

  • The redaction surface is mandatory. Per Rule #1 redaction matrix, the granter must understand what crosses the boundary and what doesn't. The two-column ✅ / ❌ list is not collapsible — it always renders. (We accept the vertical real estate; Stefan flagged transparency as load-bearing for Rule #1's "go on holiday" scenario.)
  • Notification disclosure is part of the surface, not a separate step. Per Rule #1 § acceptance criteria: "the grant is created without the client's consent" — but the client is not silently in the dark either. Per § Notifications item 5: the client IS notified when a Rule #1 grant about them is created. Correction to the wireframe text above: it should read "Lukas will be notified in-app" — see Open question #1. The wireframe text reflects the proposed-default close; the alternative is "Lukas not notified" (the V1 silent-share posture), which is materially weaker for client trust.
  • Final CTA "Grant access" — single tap, no second confirmation modal. The reversibility note covers the "did I just do something I'll regret?" worry.
  • On submit: server creates one order_shares row per selected order, all with the same grantee_stringer_id. Per ADR-0005 § Share lifecycle hooks, the same transaction writes a share_granted_to_me notification to the grantee for each row. (V2 dedup: dedup_key = share_granted_to_me:{order_share_id} per row; one notification per order.) The originating stringer optionally also queues a Rule #1 client-notification per Open question #1.
  • Success state: redirect to the "Grants I've issued" list with a toast: "Granted access to 2 jobs to Sarah Reber. [Undo]"

The Undo affordance in the toast is a 5-second window where tapping it revokes all of the just-created order_shares rows (one transaction). After 5 seconds the toast dismisses; revocation is still available from the list. This is a UX safety net for the rare misclick on the final CTA, without forcing a confirmation modal on every grant.

Stage 2 — List active grants + revoke (stringer-side)

The "Sharing" landing page's first two sections — issued + received.

sm 375 px (mobile-first)

┌───────────────────────────────────────┐
│ ←  Sharing                            │  Header
├───────────────────────────────────────┤
│                                       │
│  Grants I've issued           5 active│  H2 + count
│ ┌───────────────────────────────────┐ │
│ │ ➡ Sarah Reber                     │ │  Group by grantee
│ │   2 jobs · since 2026-04-30       │ │
│ │                          Manage  →│ │
│ └───────────────────────────────────┘ │
│ ┌───────────────────────────────────┐ │
│ │ ➡ Marc Egli                       │ │
│ │   3 jobs · since 2026-03-20       │ │
│ │                          Manage  →│ │
│ └───────────────────────────────────┘ │
│                                       │
│  ┌─────────────────────────────────┐  │
│  │  + Issue new grant              │  │  CTA
│  └─────────────────────────────────┘  │
│                                       │
│  Grants received              2 active│  H2 + count
│ ┌───────────────────────────────────┐ │
│ │ ⬅ from Lisa Berger      Rule #1   │ │
│ │   1 job · since 2026-04-25        │ │
│ │                          View   → │ │
│ └───────────────────────────────────┘ │
│                                       │
│  Audit log                            │  H2
│ ┌───────────────────────────────────┐ │
│ │ 2026-05-02 09:14                  │ │
│ │ Sarah Reber viewed #2026-0038     │ │
│ └───────────────────────────────────┘ │
│ ⋯                                     │
│  See full log →                       │  Link
│                                       │
└───────────────────────────────────────┘

"Grants I've issued" — manage view

Tapping a grantee row opens the per-grantee detail:

┌───────────────────────────────────────┐
│ ←  Granted to Sarah Reber             │  Header
├───────────────────────────────────────┤
│                                       │
│  Active jobs (2)                      │
│ ┌───────────────────────────────────┐ │
│ │ #2026-0038 · 2026-04-15           │ │
│ │ Lukas Müller · Pure Aero 98       │ │
│ │ Granted 2026-04-30                │ │
│ │ Last viewed 2026-05-02 09:14      │ │
│ │                       [Revoke]   ⓘ│ │  Inline revoke
│ └───────────────────────────────────┘ │
│ ┌───────────────────────────────────┐ │
│ │ #2026-0042 · 2026-04-30           │ │
│ │ Lukas Müller · Pure Aero 98       │ │
│ │ Granted 2026-04-30                │ │
│ │ Not viewed yet                    │ │
│ │                       [Revoke]   ⓘ│ │
│ └───────────────────────────────────┘ │
│                                       │
│  ┌─────────────────────────────────┐  │
│  │  Revoke all 2                   │  │  Bulk revoke
│  └─────────────────────────────────┘  │
│                                       │
│  Revoked (3)                          │  Collapsed by default
│  ▼                                    │
│                                       │
└───────────────────────────────────────┘

Behaviour:

  • Per-row Revoke: single tap, confirmation toast: "Revoked access to #2026-0038. [Undo]" with the same 5-second undo window. Sets order_shares.revoked_at = now(). ADR-0005 hooks fire share_revoked_from_me to the grantee.
  • "Last viewed" pulls the most recent share_audit row of kind shared_read for this order_share. Surfaces "is this grant being used?" — in admin (Stefan) terms, helps decide whether to revoke unused grants.
  • "Revoked (N)" disclosure: collapsed <details> block listing previously-revoked rows for transparency. Each row shows revoke-time + revoker (could be self or grantee). Cannot un-revoke (re-grant is a fresh issue grant; the audit log is intact).
  • Bulk "Revoke all N" revokes every active row in the group in one transaction; same toast + undo window.

"Grants received" — view

Tapping a received-grant row opens the Shared-with-me inbox detail page. The receiving-stringer surface is detailed there. The Sharing landing page just lists the grants; the detail view is the inbox.

The receiving stringer can also revoke (refuse) a grant. Per § Edit + revoke semantics: "Stringer A or Stringer B can revoke." A grantee revoking is functionally "I don't want this anymore"; same revoked_at write.

Component breakdown

Component Notes
Section H2 + count chip Same shape as the dashboard ("5 active").
Grantee group row rounded-lg, white surface, shadow-card on sm, border on md+. Whole row is a tap target → manage view.
Job row inside manage view Smaller; per-row Revoke button right-aligned (44×44 px hit area); muted "Last viewed" line.
Revoke button text-small, red-700 text on white background, border-red-200, rounded. Confirms via the toast-undo pattern, not a modal.
Inline info icon (ⓘ) Tap reveals the Rule #1 redaction matrix recap (re-using the same content as Stage 1.3) — affords "what is Sarah seeing again?" without opening another screen.
Bulk revoke button Same style as Revoke but full-width, text-body.
Audit-log preview Last 5 entries in chronological-desc order. text-small. "See full log →" link to the audit-log surface (next section).

Stage 3 — Audit log surface

Per § Audit + ADR-0004 § Audit: every grant create + grant revoke + every shared-data access is logged in share_audit. The stringer-facing surface here is the stringer's own slice — events where the stringer is either the actor or the subject.

┌───────────────────────────────────────┐
│ ←  Audit log                          │
├───────────────────────────────────────┤
│                                       │
│  Filters                              │
│ ┌───────────────────────────────────┐ │
│ │ Event:  All ▾                     │ │
│ └───────────────────────────────────┘ │
│ ┌───────────────────────────────────┐ │
│ │ Date:   Last 30 days ▾            │ │
│ └───────────────────────────────────┘ │
│                                       │
│  2026-05-02                           │  Date group
│ ┌───────────────────────────────────┐ │
│ │ 09:14  Sarah Reber viewed         │ │
│ │        #2026-0038 (Lukas Müller)  │ │
│ │        via your grant             │ │
│ └───────────────────────────────────┘ │
│ ┌───────────────────────────────────┐ │
│ │ 09:00  You granted access to      │ │
│ │        Sarah Reber on 2 jobs      │ │
│ │        for Lukas Müller           │ │
│ └───────────────────────────────────┘ │
│                                       │
│  2026-04-30                           │
│ ⋯                                     │
│                                       │
│  Older →                              │  Pagination
│                                       │
└───────────────────────────────────────┘

Behaviour:

  • Read-only. No edit / delete affordances; per ADR-0004 § Audit, the table is append-only.
  • Filter by event kind: All, Grants created, Grants revoked, Shared reads. Maps to share_audit.event_kind.
  • Filter by date: Today, Last 7 days, Last 30 days, All time.
  • Group by date for readability; within a date, sorted desc by at.
  • Per-event copy:
  • grant_created — "{actor} granted access to {grantee} on {N} job(s) for {client}". (Verb localised; entity names are data.)
  • grant_revoked — "{actor} revoked access to {grantee} on {N} job(s)".
  • shared_read — "{grantee} viewed #{order.receipt_number} ({client}) via your grant" (when the stringer is the granter) OR "You viewed #{order.receipt_number} ({client}) via {granter}'s grant" (when the stringer is the grantee).
  • Pagination: 25 events per page; "Older →" link increments offset. (Per ADR-0005 § Inbox query's precedent — V2 offset; switch to keyset only if volume requires.)

Audit transparency for the client (V3-deferred)

Per § Audit + § User stories — Client: "As a client, I receive a notification when a stringer (Rule 1) shares a job concerning me with another stringer, including which fields the other stringer can see (transparency on the redaction policy)."

The V3 client portal will surface a parallel "Who has seen my data" view, scoped to events where the client (Person) is the subject:

┌───────────────────────────────────────┐
│ ←  Who has seen my data               │
├───────────────────────────────────────┤
│                                       │
│  Active grants about you              │
│ ┌───────────────────────────────────┐ │
│ │ Sarah Reber (stringer)            │ │
│ │ Granted by Stefan Wagen           │ │
│ │ on 2 of your jobs                 │ │
│ │ since 2026-04-30                  │ │
│ │ Last viewed 2026-05-02 09:14      │ │
│ │                  [Ask to revoke]  │ │  Per Rule #1, only granter
│ └───────────────────────────────────┘ │  can revoke (see note).
│                                       │
│  Audit log                            │
│ ⋯                                     │
│                                       │
└───────────────────────────────────────┘

V3-deferred behaviour:

  • The client cannot directly revoke a Rule #1 grant (it's the stringer's own work product per § Rule 1). The "Ask to revoke" action sends a notification to the granting stringer asking them to revoke. The stringer decides; the audit log records the request.
  • Rule #2 + Rule #3 grants (when the client is the granter) ARE revocable from this surface.
  • The redaction-policy transparency: tapping a grant row reveals the same ✅ / ❌ list as the stringer-side Stage 1.3 confirmation. The client sees exactly what crossed the boundary.

This is explicitly V3-deferred. The wireframe is here to lock the design language; the V3 implementation surface is gated on the client portal landing.

Client-side surfaces (V3 portal preview)

Three additional sections in the V3 client portal, mirroring the stringer-side IA but pivoted on the Person.

V3 — Rule #2 grant: client-initiated per-job share

Mirror of Stage 1.1–1.3, with two differences:

  1. Step 1.1 picks from the client's merged history — every Strung+ order across every stringer the client has used. Sorted by strung_at desc; grouped by stringer.
  2. Step 1.3 redaction surface flips: under Rule #2 redaction, pricing IS visible (the client is the granter — it's their own data). The ✅ / ❌ list shows: ✅ everything technical, ✅ full identity (the client is consenting), ✅ pricing, ✅ comments. There is essentially nothing redacted under Rule #2 — the surface still shows the matrix for transparency.

V3 — Rule #3 grant: client global preference

A different surface — toggle-driven, not job-picker-driven:

┌───────────────────────────────────────┐
│ ←  Share everything                   │
├───────────────────────────────────────┤
│                                       │
│  Pick a stringer to share all your    │
│  past + future jobs with.             │
│                                       │
│ ┌───────────────────────────────────┐ │
│ │ 🔍 Search stringers               │ │
│ └───────────────────────────────────┘ │
│                                       │
│  Currently sharing everything with:   │
│ ┌───────────────────────────────────┐ │
│ │ ✓ Stefan Wagen                    │ │
│ │   since 2025-08-15                │ │
│ │   Last viewed 2026-05-02 09:14    │ │
│ │              [Stop sharing]       │ │
│ └───────────────────────────────────┘ │
│                                       │
│  Other stringers                      │
│ ┌───────────────────────────────────┐ │
│ │ ◯ Sarah Reber                     │ │
│ │              [Start sharing]      │ │
│ └───────────────────────────────────┘ │
│ ⋯                                     │
│                                       │
└───────────────────────────────────────┘

V3-deferred behaviour:

  • Per § Rule 3 acceptance criteria: toggling on includes past + future, across current AND future stringers the client may be added to. The toggle is a per-(person, target_stringer) row in person_stringer_share.
  • Surprise-mitigation flag (per ADR-0004 § Costs we accept): when the client lists active grants here, Rule #3 grants are distinctly flagged. Treatment: the chip "shares everything (past + future)" in text-tiny amber-700 text on amber-50 background, alongside a circle-alert Lucide icon. Stronger affordance than Rule #2.
  • "Last viewed" again: surfaces "is Stringer X actually using this?" — informs the client's decision to keep / revoke.
  • "Stop sharing" sets revoked_at; per § Edit + revoke semantics revocation is immediate; Stringer X stops seeing the client's jobs from the next request.

V3 — Notifications received (client-side)

Per § Notifications:

  • "Stefan Wagen shared 2 of your jobs with Sarah Reber." — incoming notification when a stringer creates a Rule #1 grant about the client.
  • "Sarah Reber stopped sharing with you." — when a Rule #2/#3 grantee revokes.
  • "Sarah Reber viewed a job you shared." — optional, opt-in per Person.notification_prefs (could be noisy; defaults to off).

Notification surface: a parallel inbox on the client portal, mirroring the stringer-side notifications inbox (out of Round-2 scope; a Round 3 surface).

Notifications — design surface (V2)

This page commits the affordance for grant-related notifications; the full notification inbox UI is a separate surface (per stringer-dashboard § Inbox section).

  1. Dashboard inbox section — the existing notification badge on the stringer dashboard increments when share_granted_to_me, share_revoked_from_me, or my_order_visible_to_third_party rows arrive (per ADR-0005 § notification_kind enum).
  2. "Shared with me" inbox — for share_granted_to_me, the inbox page (see shared-with-me-inbox) shows the new grant inline; clicking through marks the notification read.
  3. Toast on this page — when the user is on the Sharing page and a real-time event fires (e.g. a grantee revokes a grant they had received), the affected row in "Grants I've issued" updates with a motion-default 200 ms cross-fade and a toast: "Sarah revoked your grant on #2026-0038." (V2.x polish; HTMX hx-swap-oob is the implementation seam.)

Channel rules

Per OQ-3 closure:

  • In-app is mandatory for every grant/revoke event — always fires. The user cannot disable.
  • Email is opt-in per recipient via Person.notification_prefs. Default value is off for share_* kinds in V2 (Stefan flagged: avoid surprise emails as the platform onboards). Can be flipped from account settings.

The opt-in toggle lives in the stringer's account settings (out of Round-2 scope; a Round 3 surface). For Round 2, the surface here notes that email is opt-in and points stringers at the (future) settings page.

Interaction states

State What renders
Empty (no grants issued + no grants received) "You haven't shared any jobs yet, and no one has shared jobs with you. [Issue new grant]" with a single primary CTA.
Empty (no grants issued, but received some) The "Grants I've issued" section header still renders with "0 active" + the CTA; "Grants received" lists the inbound grants normally.
Loading (initial) Server-rendered HTML, no skeleton.
Loading (HTMX swap, e.g. after revoke) The affected row swaps with a 200 ms cross-fade.
Revoke pending The Revoke button shows "Revoking…" with a 16 px spinner. Disabled while in flight.
Revoke success Toast "Revoked access to #2026-0038. [Undo]" with 5-second undo window. The row moves into the "Revoked (N)" collapsed group.
Revoke failure (5xx) Toast "Couldn't revoke right now — try again." Row stays in active list; Revoke button re-enables.
Grant create — undo window active Toast "Granted access to 2 jobs to Sarah Reber. [Undo]" with countdown bar (5 seconds).
Grant create — undo tapped Toast replaces with "Reverted." Server has un-set revoked_at to now() on the just-created rows; ADR-0005 hooks fire share_revoked_from_me to the grantee for each.
Audit log filter — empty match "No events match these filters." with a "Clear filters" link.

Validation rules (UI surface; canonical server-side)

Rule Inline message
Grant — at least one order picked "Pick at least one job to share."
Grant — grantee not chosen "Pick who to share with."
Grant — grantee equals self "You can't grant access to yourself." (Server-side guard; UI also filters self out of the picker.)
Grant — order is not yet Strung "Past jobs only — pick a job that's been strung." (Server-side guard; UI picker filters by strung_at IS NOT NULL.)
Grant — order is not this stringer's "You can only share jobs you performed." (Server-side guard.)
Revoke — already revoked "This grant was already revoked." (Stale-state race; show + refresh.)

Accessibility

  • Keyboard navigation through the multi-step grant flow: Tab cycles through job checkboxes within a client group, then "Share all N" shortcut, then next group; Continue CTA at the end.
  • Checkbox group in Step 1.1 has <fieldset> per client group with a <legend> carrying the client name + count, so screen readers announce "Lukas Müller, 7 jobs" before reading individual job rows.
  • Redaction surface (Stage 1.3) uses <ul> with aria-label="Fields visible to grantee" and aria-label="Fields hidden from grantee". The ✅ / ❌ icons are decorative; the textual list carries the meaning.
  • Notification disclosure copy is a real <p>, not a tooltip; ensures screen-reader users get it.
  • Revoke confirmation toast with Undo: the toast has role="status" and aria-live="polite"; the Undo button is keyboard-focusable for the 5-second window. ESC dismisses the toast (without firing Undo).
  • Hit targets: every checkbox + Revoke button + Grantee row ≥ 44 × 44 px.
  • Focus management: advancing through Stage 1.1 → 1.2 → 1.3, focus moves to the H1 of the new step. Returning via the back arrow restores focus to the previously-actioned element (e.g. the Continue button).
  • Color is never the only signal: ✅ / ❌ in the redaction surface are paired with the text "Visible:" / "Hidden:" headers; the audit-log event-kind filter chips use icons (Lucide gift, circle-x, eye) AND text labels.

HTMX / progressive-enhancement seams

  • Step 1.1 client filter: hx-get="/sharing/_orders?q=..." hx-trigger="keyup changed delay:200ms" hx-target="#order-picker".
  • Step 1.1 → 1.2 → 1.3 transitions: ideally an HTMX swap of the page body (hx-push-url="true" so back-button works). Without JS: each step is a regular form POST to /sharing/grant?step=N with the in-progress state in form fields.
  • Bulk shortcut ("Share all 7 of Lukas's jobs"): native HTML — a submit button that POSTs client_profile_id=... and re-renders Step 1.2 with all of Lukas's orders pre-checked. With JS, HTMX swaps the order-picker region in place.
  • Revoke (per-row): hx-post="/sharing/grants/{order_share_id}/revoke" hx-swap="outerHTML" hx-target="closest .grant-row". Without JS: a regular POST that 303-redirects back to the manage view. End state identical.
  • Bulk revoke: same pattern, server-side endpoint accepts the grantee_id and revokes every active row.
  • Undo (toast button): hx-post="/sharing/grants/_undo/{batch_id}" hx-trigger="click". The batch_id is generated on grant create and tracks the just-created rows. After 5 seconds the batch_id expires server-side; clicking Undo after expiry shows "Too late — revoke from the list."
  • Audit-log filters: hx-get="/sharing/audit?event=...&date=..." hx-trigger="change" hx-target="#audit-list". Without JS: regular GET with query params; full page reload.

The whole flow's fundamental contract is "regular form POSTs". HTMX swaps are surgical optimisations.

i18n affordance

String Type Catalogue key
"Sharing" (page title) {% trans %} sharing.title
"Grants I've issued" / "Grants received" / "Audit log" {% trans %} sharing.section.{issued,received,audit}
"N active" {% trans %} sharing.count.active
"Issue new grant" {% trans %} sharing.cta.new_grant
"Pick jobs to share" / "Past jobs only." {% trans %} sharing.grant.{pick_jobs,past_only}
"Filter by client" {% trans %} sharing.grant.filter_client
"Share all N of {client}'s jobs" {% trans %} (Babel) sharing.grant.bulk_for_client
"Share N jobs with" {% trans %} (Babel) sharing.grant.with_label
"Search stringers" {% trans %} sharing.grant.search_stringers
"Recent grantees" / "All stringers" {% trans %} sharing.grant.{recent,all}
"Confirm" {% trans %} sharing.grant.confirm
"You're granting {grantee} read-only access to:" {% trans %} (Babel) sharing.grant.confirm.intro
"What {grantee} will see" {% trans %} (Babel) sharing.grant.confirm.redaction_h2
Visible/Hidden checklist items {% trans %} sharing.grant.confirm.{visible,hidden}.<row>
"{grantee} will be notified in-app." / "{client} will be notified in-app." {% trans %} (Babel) sharing.grant.confirm.notif_grantee / sharing.grant.confirm.notif_client
"You can revoke this grant any time from your 'Grants I've issued' list." {% trans %} sharing.grant.confirm.reversibility
"Grant access" (final CTA) {% trans %} sharing.grant.confirm.cta
"Granted access to {N} jobs to {grantee}. [Undo]" (toast) {% trans %} (Babel) sharing.grant.toast.success
"Revoked access to #{number}. [Undo]" (toast) {% trans %} (Babel) sharing.revoke.toast.success
"Revoke" / "Revoke all N" {% trans %} sharing.revoke.{single,bulk}
"Last viewed {date}" / "Not viewed yet" {% trans %} (Babel) sharing.last_viewed.{set,never}
Audit-log event copy {% trans %} (Babel, with placeholders) sharing.audit.event.{grant_created,grant_revoked,shared_read.granter,shared_read.grantee}
"Older →" {% trans %} sharing.audit.older
"All events" / "Grants created" / "Grants revoked" / "Shared reads" {% trans %} sharing.audit.filter.{all,created,revoked,read}
"Today" / "Last 7 days" / "Last 30 days" / "All time" {% trans %} sharing.audit.filter.date.{today,7d,30d,all}
Stringer name / client name (data) Data n/a
Receipt number / dates / counts Format (Babel) n/a

Iris's DE pass follows after merge; Mira commits the catalogue keys + EN values.

DE width budget (designer note)

DE labels here are typically 20–35% longer than EN. Specific watch-outs:

  • "Grants I've issued" → "Erteilte Freigaben" (similar).
  • "Grants received" → "Erhaltene Freigaben" (similar).
  • "Issue new grant" → "Neue Freigabe erteilen" (~1.4×).
  • "Past jobs only." → "Nur abgeschlossene Aufträge." (~1.6×) — wraps cleanly under the H1.
  • "What Sarah will see" → "Was Sarah sehen wird" (similar).
  • "You can revoke this grant any time from your 'Grants I've issued' list." → "Sie können diese Freigabe jederzeit über 'Erteilte Freigaben' widerrufen." (similar) — the long sentence wraps to two lines on sm; reserve room.
  • "Grant access" → "Zugriff erteilen" (~1.5×) — sticky CTA still fits within full-width minus 16 px padding.

Open questions for Stefan (with proposed defaults)

  1. Should the client be notified in-app when a Rule #1 grant is created about them? Per § User stories — Client: "As a client, I receive a notification when a stringer (Rule 1) shares a job concerning me with another stringer..." This is explicit in the requirements. Proposed default: yes — fire share_granted_to_me to the client (Person), surfaced via in-app notification when the V3 portal lands; in V2, log the event to share_audit and queue an opt-in email if the Person has notification_prefs.share_granted=true. The wireframe Stage-1.3 confirmation copy reflects this default. Alternative (the V1 silent-share posture): no client notification — a Rule #1 grant is between stringers only. Stefan flagged transparency as load-bearing; I think the requirements text already locks this decision and I should treat it as confirmed; flagged here in case Stefan wants to soften it for V2 and ship the client-notify in V3 with the portal.

  2. Undo window duration on grant create + revoke. Proposed default: 5 seconds. Long enough for "wait, that was the wrong stringer" reflex; short enough that the grantee receives the notification at most ~5s late. Alternative: 10 seconds (more forgiving) or no undo (forces a confirmation modal in its place). 5s + no-modal is a productivity-first stance.

  3. "Recent grantees" list cap in Step 1.2. Proposed default: 5. With Stefan's solo workflow + occasional handoff to a colleague, his recent-grantees set is plausibly 1–3 distinct stringers. Cap at 5 leaves room for growth without crowding the "All stringers" list out.

  4. Bulk shortcut "Share all N of {client}'s jobs" wording when N is large. Proposed default: keep the count visible — "Share all 47 of Lukas's jobs". Surfaces the magnitude before the click; reduces accidental high-cardinality grants. Alternative: "Share all jobs" (cleaner, ambiguous about scope). The verbose form is opinionated for safety.

  5. Revoke confirmation pattern: toast-undo (current proposal) vs explicit modal. Proposed default: toast-undo, 5s. Aligned with the new-job-save flow's productivity-first stance. The Stefan-on-vacation use case (revoke 7 jobs quickly) wants no modal friction. Alternative: a confirmation modal on revoke (one extra tap per revoke). Stefan to confirm.

  6. Audit-log default date filter. Proposed default: Last 30 days. Most actionable window; older events are still reachable via the "All time" filter. Alternative: "Last 7 days" (tighter, good for active-monitoring) or "All time" (overwhelming on mature accounts). Mid-30 is a comfortable middle.

  7. "Last viewed" surface on inactive grants — when a grantee never opened a shared job, the row reads "Not viewed yet". After a long no-view period (e.g. 90 days), should the surface flag the grant for revoke? Proposed default: no auto-flag in V2. Surface the date passively; the stringer can decide. A "stale-grant nudge" is a Round-3 polish. Alternative: a chip "Not used in 90 days — revoke?" on grants older than the threshold. Adds noise; flagged for later.

  8. V3 client-side "Ask to revoke" Rule #1 — when a client wants a Rule #1 grant revoked but cannot directly (only the granting stringer can). Proposed default: send a notification + log the request in share_audit; the granter sees an inbox item "Lukas asked you to revoke Sarah's access to his jobs." The granter decides (revokes or replies). Alternative: hard force-revoke on the client's request (overrides the granter). Hard-force violates the "Rule #1 is the stringer's own work product" stance. Soft-ask matches the requirements text. V3-deferred either way.

  9. Audit-log "your own actions" filter. Currently the log shows everything the stringer is party to (granter or grantee or subject). Should there be a "Show only my actions" toggle? Proposed default: no V2 toggle; defer to whether Stefan reports the log feels noisy. If Stefan wants a quick scan of "what did I personally do recently", event_kind=grant_created + actor-self filter is the SQL; the UI is a one-checkbox addition.

Cross-references