"Now" status scan experience — show what you're currently up to
CI / policy (push) Has been cancelled
CI / backend (push) Has been cancelled
CI / profile (push) Has been cancelled
CI / mobile (push) Has been cancelled

Experience #9 (lite): a published card can carry a "Now" line shown with a live
pulsing dot, so a scanned profile reflects what the person is currently doing.

- Backend: demo publish accepts optional `now` -> definition.profile.now; test
  asserts it. Gate green (120 tests).
- Web: renders a "Now: …" block with an animated green dot. astro check clean.
- Mobile: a "Now" field on the publish screen.

Verified live alongside a link payload (CTA button).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-05 21:15:40 -05:00
co-authored by Claude Opus 4.8
parent a61d0221d1
commit b487195def
6 changed files with 60 additions and 1 deletions
@@ -41,6 +41,9 @@ pub struct DemoPublishRequest {
pub welcome_message: Option<String>, pub welcome_message: Option<String>,
#[serde(default)] #[serde(default)]
pub payload: Option<DemoPayload>, pub payload: Option<DemoPayload>,
/// "Now" status — what the person is currently up to.
#[serde(default)]
pub now: Option<String>,
} }
pub struct Published { pub struct Published {
@@ -90,6 +93,11 @@ pub async fn publish(state: &AppState, req: &DemoPublishRequest) -> Result<Publi
}); });
} }
} }
if let Some(now) = req.now.as_deref() {
if !now.trim().is_empty() {
profile["now"] = serde_json::json!(now.trim());
}
}
let definition = serde_json::json!({ let definition = serde_json::json!({
"face": { "layers": [], "background": { "type": "solid", "value": "#101014" } }, "face": { "layers": [], "background": { "type": "solid", "value": "#101014" } },
"back": { "back": {
@@ -345,7 +345,8 @@ async fn demo_publish_creates_scannable_profile_with_welcome() {
"links": [{ "label": "Site", "url": "https://cardclaws.com" }], "links": [{ "label": "Site", "url": "https://cardclaws.com" }],
"welcomePrompt": "a calm forest at dawn", "welcomePrompt": "a calm forest at dawn",
"welcomeMessage": "Great to meet you", "welcomeMessage": "Great to meet you",
"payload": { "kind": "code", "label": "20% off", "value": "CARD20" } "payload": { "kind": "code", "label": "20% off", "value": "CARD20" },
"now": "Building CardClaws · open to design partners"
})), })),
) )
.await; .await;
@@ -364,6 +365,10 @@ async fn demo_publish_creates_scannable_profile_with_welcome() {
profile["definition"]["profile"]["payload"]["value"], profile["definition"]["profile"]["payload"]["value"],
"CARD20" "CARD20"
); );
assert_eq!(
profile["definition"]["profile"]["now"],
"Building CardClaws · open to design partners"
);
} }
#[tokio::test] #[tokio::test]
+12
View File
@@ -31,6 +31,7 @@ export default function PublishScreen() {
const [payloadKind, setPayloadKind] = useState<"none" | "link" | "code">("none"); const [payloadKind, setPayloadKind] = useState<"none" | "link" | "code">("none");
const [payloadLabel, setPayloadLabel] = useState(""); const [payloadLabel, setPayloadLabel] = useState("");
const [payloadValue, setPayloadValue] = useState(""); const [payloadValue, setPayloadValue] = useState("");
const [now, setNow] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [result, setResult] = useState<string | null>(card?.publishedUrl ?? null); const [result, setResult] = useState<string | null>(card?.publishedUrl ?? null);
@@ -56,6 +57,7 @@ export default function PublishScreen() {
payloadKind !== "none" && payloadValue.trim() payloadKind !== "none" && payloadValue.trim()
? { kind: payloadKind, label: payloadLabel.trim(), value: payloadValue.trim() } ? { kind: payloadKind, label: payloadLabel.trim(), value: payloadValue.trim() }
: undefined, : undefined,
now: now.trim() || undefined,
}); });
// Point the card's QR at the real profile + remember it. // Point the card's QR at the real profile + remember it.
upsert({ ...card, url: profileUrl, publishedUrl: profileUrl, updatedAt: Date.now() }); upsert({ ...card, url: profileUrl, publishedUrl: profileUrl, updatedAt: Date.now() });
@@ -101,6 +103,16 @@ export default function PublishScreen() {
</> </>
)} )}
<Text style={styles.label}>Now — what you’re up to (optional)</Text>
<TextInput
style={styles.input}
editable={!busy}
placeholder="e.g. Building CardClaws · open to design partners"
placeholderTextColor="#6b6b70"
value={now}
onChangeText={setNow}
/>
<Text style={styles.label}>Payload drop (optional)</Text> <Text style={styles.label}>Payload drop (optional)</Text>
<View style={styles.kindRow}> <View style={styles.kindRow}>
{(["none", "link", "code"] as const).map((k) => ( {(["none", "link", "code"] as const).map((k) => (
+1
View File
@@ -57,6 +57,7 @@ export interface PublishPayload {
welcomePrompt?: string; welcomePrompt?: string;
welcomeMessage?: string; welcomeMessage?: string;
payload?: { kind: "link" | "code"; label: string; value: string }; payload?: { kind: "link" | "code"; label: string; value: string };
now?: string;
} }
/** Publish a demo card to the web (no auth) → a real, scannable profile URL. */ /** Publish a demo card to the web (no auth) → a real, scannable profile URL. */
+2
View File
@@ -43,6 +43,8 @@ export interface ProfileData {
contactFormEnabled?: boolean; contactFormEnabled?: boolean;
/** "Payload drop": the thing handed over on scan (a link CTA or a code). */ /** "Payload drop": the thing handed over on scan (a link CTA or a code). */
payload?: { kind: "link" | "code"; label: string; value: string }; payload?: { kind: "link" | "code"; label: string; value: string };
/** "Now" status — what the person is currently up to. */
now?: string;
} }
export interface CardSide { export interface CardSide {
@@ -26,6 +26,7 @@ const profileData = profile.definition.profile ?? {};
const bio = profileData.bio?.trim(); const bio = profileData.bio?.trim();
const links = (profileData.links ?? []).filter((l) => l.url.trim().length > 0); const links = (profileData.links ?? []).filter((l) => l.url.trim().length > 0);
const payload = profileData.payload && profileData.payload.value?.trim() ? profileData.payload : null; const payload = profileData.payload && profileData.payload.value?.trim() ? profileData.payload : null;
const now = profileData.now?.trim();
const vcard = buildVcard(name, contact); const vcard = buildVcard(name, contact);
const og = `${name}${contact.title ? ` — ${contact.title}` : ""}${ const og = `${name}${contact.title ? ` — ${contact.title}` : ""}${
contact.company ? ` at ${contact.company}` : "" contact.company ? ` at ${contact.company}` : ""
@@ -55,6 +56,12 @@ const og = `${name}${contact.title ? ` — ${contact.title}` : ""}${
contact={contact} contact={contact}
/> />
{now && (
<section class="block">
<p class="now"><span class="now-dot"></span>Now: {now}</p>
</section>
)}
{bio && ( {bio && (
<section class="block"> <section class="block">
<h2>About</h2> <h2>About</h2>
@@ -155,6 +162,30 @@ const og = `${name}${contact.title ? ` — ${contact.title}` : ""}${
line-height: 1.5; line-height: 1.5;
color: #d8d8dc; color: #d8d8dc;
} }
.now {
margin: 0;
display: flex;
align-items: center;
gap: 8px;
color: #f5f5f7;
font-weight: 600;
}
.now-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: #30d158;
box-shadow: 0 0 0 0 rgba(48, 209, 88, 0.6);
animation: pulse 1.8s infinite;
}
@keyframes pulse {
70% {
box-shadow: 0 0 0 8px rgba(48, 209, 88, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(48, 209, 88, 0);
}
}
.payload-link { .payload-link {
display: block; display: block;
text-align: center; text-align: center;