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¶
- client-identity-and-sharing § Rule 1 — stringer-side per-job grant, past-only, multi-job bulk.
- client-identity-and-sharing § Rules 2 + 3 — client-side per-job (Rule 2) + client-side global (Rule 3). V3-portal-gated; designed in V2, surfaced in V3.
- client-identity-and-sharing § Hidden requirements — sharing-management screen (stringer side); sharing-management screen (client side, V3); audit log surface; notification surfacing.
- client-identity-and-sharing § Audit — every grant create + revoke + access logged; admin-visible at minimum (V2); per-client transparency in V3.
- ADR-0005 § notification_kind enum —
share_granted_to_me,share_revoked_from_me,my_order_visible_to_third_partyare the notification kinds this surface emits / consumes.
Goal¶
Make the act of sharing explicit, reversible, and observable:
- The stringer doing the sharing knows exactly what they're handing over (which jobs, to whom, with what redaction).
- The receiving stringer knows what they got and can refuse it (revoke).
- The client knows what was shared about them, and to whom, and when (the audit log surfaces this in V3).
- 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_atdesc. - 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
keyupHTMX 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_sharesrow per order, revocable individually. - Selection counter + Continue CTA sticky-bottom on
sm; in the form footer onmd+. 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_atonorder_sharesrows wheregranter_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@handleexists, 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_sharesrow per selected order, all with the samegrantee_stringer_id. Per ADR-0005 § Share lifecycle hooks, the same transaction writes ashare_granted_to_menotification 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 fireshare_revoked_from_meto the grantee. - "Last viewed" pulls the most recent
share_auditrow of kindshared_readfor thisorder_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 toshare_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:
- Step 1.1 picks from the client's merged history — every Strung+ order across every stringer the client has used. Sorted by
strung_atdesc; grouped by stringer. - 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-tinyamber-700text onamber-50background, alongside acircle-alertLucide 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).
Three V2 surfaces consume share-related notifications¶
- Dashboard inbox section — the existing notification badge on the stringer dashboard increments when
share_granted_to_me,share_revoked_from_me, ormy_order_visible_to_third_partyrows arrive (per ADR-0005 § notification_kind enum). - "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. - 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-default200 ms cross-fade and a toast: "Sarah revoked your grant on #2026-0038." (V2.x polish; HTMXhx-swap-oobis 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 forshare_*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>witharia-label="Fields visible to grantee"andaria-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"andaria-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=Nwith 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)¶
-
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_meto the client (Person), surfaced via in-app notification when the V3 portal lands; in V2, log the event toshare_auditand queue an opt-in email if the Person hasnotification_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. -
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.
-
"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.
-
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.
-
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.
-
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.
-
"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.
-
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. -
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¶
- Source requirements: client-identity-and-sharing (rules + audit + notifications).
- Architecture: ADR-0004 (schema + serialization-layer redaction), ADR-0005 (notification kinds, hooks).
- Linked design surfaces: shared-with-me-inbox (the receiving stringer's detail surface), stringer-dashboard (entry point + notification badge), design-tokens.
- i18n strategy: i18n architecture.
- Issue tracking: racket-book#102.