@amityco/social-plus-vise 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,19 @@ All notable changes to `@amityco/social-plus-vise` are documented in this file.
4
4
 
5
5
  The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## 0.14.0 — 2026-06-03
8
+
9
+ **Theme:** DesignBuildBrief — plan-time, grounded UI-building guidance for coding agents (advisory).
10
+
11
+ ### Added
12
+ - **DesignBuildBrief in `vise plan`** (`designContract.brief`): conservative semantic token roles inferred from token NAMES only (noun-first compounds — `--text-primary` binds `textPrimary`, never `primaryAction`; value-based inference is forbidden), token-derived component hints (card/button/input) with explicit absent variants, and grounded do/avoid lines — every line cites the declared tokens/roles it derives from, and an ungrounded line is structurally impossible. For `add-feed` / `add-chat` outcomes the brief carries a conditional **outcome recipe** whose items only reference roles that were actually inferred. Generated at plan time — never persisted to `sp-vise/`, never part of any digest.
13
+ - **Non-blocking `primary_action_token` intake question** when a design contract exists for a feed/chat outcome but no primary-action token was confidently identified — the agent asks instead of guessing.
14
+
15
+ ### Notes
16
+ - Advisory only; never gates `vise check`. **No design-conformance improvement is claimed for the brief yet** — a pre-registered ablation (`benchmarks/brief-ablation/PROTOCOL.md`: 0.13.0 vs 0.14.0, same contract, n=3, Cursor/Composer 2.5) measures its effect; documentation claims will follow the measurement.
17
+
18
+ ---
19
+
7
20
  ## 0.13.0 — 2026-06-03
8
21
 
9
22
  **Theme:** Deterministic-gate soundness, CLI reliability, and post-rebrand coherence (driven by two repo reviews).
@@ -2323,3 +2323,412 @@ function stableStringify(value) {
2323
2323
  }
2324
2324
  return JSON.stringify(value);
2325
2325
  }
2326
+ /**
2327
+ * Structural grounding helper: builds a BriefLine and throws a TypeError at
2328
+ * construction time if groundedIn is empty. This makes the grounding invariant
2329
+ * structural — an ungrounded line is impossible rather than a runtime surprise.
2330
+ */
2331
+ function line(text, groundedIn, confidence = "high") {
2332
+ if (groundedIn.length === 0) {
2333
+ throw new TypeError(`BriefLine created with empty groundedIn: "${text}"`);
2334
+ }
2335
+ return { text, groundedIn, confidence };
2336
+ }
2337
+ /**
2338
+ * Per-role keyword rules in spec-defined order.
2339
+ * Each entry: [pattern, role, confidence].
2340
+ * Rules are applied with first-match-wins semantics.
2341
+ * Compound rules (muted+text, muted+bg/surface) must precede their plain
2342
+ * counterparts so "--color-text-muted" resolves to textSecondary, not textPrimary.
2343
+ */
2344
+ const ROLE_RULES = [
2345
+ // Compound rules — must precede their plain counterparts
2346
+ {
2347
+ test: (n) => /muted/.test(n) && /\btext\b|foreground|fg/.test(n),
2348
+ role: "textSecondary",
2349
+ confidence: "medium",
2350
+ reason: "name contains 'muted' and 'text'/'foreground'/'fg'",
2351
+ },
2352
+ {
2353
+ test: (n) => /muted/.test(n) && /\bbg\b|surface|background/.test(n),
2354
+ role: "surfaceMuted",
2355
+ confidence: "medium",
2356
+ reason: "name contains 'muted' and 'bg'/'surface'/'background'",
2357
+ },
2358
+ // Noun-first compounds — in real design systems the NOUN keyword (text/surface/
2359
+ // bg/border) sets the role family and primary/secondary act as modifiers within
2360
+ // it: "--text-primary" is the primary BODY-TEXT color, not the action color.
2361
+ // These must precede the plain primary/secondary rules below, or first-match-wins
2362
+ // would misbind some of the most common token names in the wild.
2363
+ {
2364
+ test: (n) => /\btext\b|foreground|\bfg\b/.test(n) && /\bprimary\b/.test(n),
2365
+ role: "textPrimary",
2366
+ confidence: "high",
2367
+ reason: "name contains 'text'/'foreground'/'fg' with 'primary' — the noun keyword sets the role family",
2368
+ },
2369
+ {
2370
+ test: (n) => /\btext\b|foreground|\bfg\b/.test(n) && /\bsecondary\b/.test(n),
2371
+ role: "textSecondary",
2372
+ confidence: "high",
2373
+ reason: "name contains 'text'/'foreground'/'fg' with 'secondary' — the noun keyword sets the role family",
2374
+ },
2375
+ {
2376
+ test: (n) => /surface|background|\bbg\b/.test(n) && /\bprimary\b/.test(n),
2377
+ role: "surface",
2378
+ confidence: "high",
2379
+ reason: "name contains 'surface'/'background'/'bg' with 'primary' — a primary surface, not an action color",
2380
+ },
2381
+ {
2382
+ test: (n) => /surface|background|\bbg\b/.test(n) && /\bsecondary\b/.test(n),
2383
+ role: "surfaceMuted",
2384
+ confidence: "medium",
2385
+ reason: "name contains 'surface'/'background'/'bg' with 'secondary' — a secondary surface",
2386
+ },
2387
+ {
2388
+ test: (n) => /\bborder\b|\boutline\b|\bdivider\b/.test(n) && (/\bprimary\b/.test(n) || /\bsecondary\b/.test(n)),
2389
+ role: "border",
2390
+ confidence: "medium",
2391
+ reason: "name contains a border keyword with a primary/secondary modifier — the noun keyword sets the role family",
2392
+ },
2393
+ // Primary: plain "primary" → high; "brand"/"accent" → medium
2394
+ {
2395
+ test: (n) => /\bprimary\b/.test(n) && !/brand|accent/.test(n),
2396
+ role: "primaryAction",
2397
+ confidence: "high",
2398
+ reason: "name contains 'primary'",
2399
+ },
2400
+ {
2401
+ test: (n) => /\bbrand\b|\baccent\b/.test(n),
2402
+ role: "primaryAction",
2403
+ confidence: "medium",
2404
+ reason: "name contains 'brand' or 'accent'",
2405
+ },
2406
+ {
2407
+ test: (n) => /\bsecondary\b/.test(n),
2408
+ role: "secondaryAction",
2409
+ confidence: "high",
2410
+ reason: "name contains 'secondary'",
2411
+ },
2412
+ {
2413
+ test: (n) => /\bdanger\b|\berror\b|\bdestructive\b/.test(n),
2414
+ role: "danger",
2415
+ confidence: "high",
2416
+ reason: "name contains 'danger', 'error', or 'destructive'",
2417
+ },
2418
+ {
2419
+ test: (n) => /\bsuccess\b|\bpositive\b/.test(n),
2420
+ role: "success",
2421
+ confidence: "high",
2422
+ reason: "name contains 'success' or 'positive'",
2423
+ },
2424
+ {
2425
+ test: (n) => /surface|background|\bbg\b/.test(n),
2426
+ role: "surface",
2427
+ confidence: "high",
2428
+ reason: "name contains 'surface', 'background', or 'bg'",
2429
+ },
2430
+ {
2431
+ test: (n) => /\btext\b|foreground|\bfg\b/.test(n),
2432
+ role: "textPrimary",
2433
+ confidence: "high",
2434
+ reason: "name contains 'text', 'foreground', or 'fg'",
2435
+ },
2436
+ {
2437
+ test: (n) => /\bborder\b|\boutline\b|\bdivider\b/.test(n),
2438
+ role: "border",
2439
+ confidence: "high",
2440
+ reason: "name contains 'border', 'outline', or 'divider'",
2441
+ },
2442
+ {
2443
+ test: (n) => /\bfocus\b|\bring\b/.test(n),
2444
+ role: "focus",
2445
+ confidence: "high",
2446
+ reason: "name contains 'focus' or 'ring'",
2447
+ },
2448
+ {
2449
+ test: (n) => /\bavatar\b/.test(n),
2450
+ role: "avatarFallback",
2451
+ confidence: "high",
2452
+ reason: "name contains 'avatar'",
2453
+ },
2454
+ ];
2455
+ /** Infer a (role, confidence, reason) from a token name, or null if no rule matches. */
2456
+ function inferRole(tokenName) {
2457
+ const n = tokenName.toLowerCase();
2458
+ for (const rule of ROLE_RULES) {
2459
+ if (rule.test(n)) {
2460
+ return { role: rule.role, confidence: rule.confidence, reason: rule.reason };
2461
+ }
2462
+ }
2463
+ return null;
2464
+ }
2465
+ /**
2466
+ * Among multiple candidates for the same role, prefer:
2467
+ * 1. high confidence over medium
2468
+ * 2. shorter name (e.g. "--color-primary" beats "--color-primary-hover")
2469
+ */
2470
+ function bestCandidate(a, b) {
2471
+ if (a.confidence === "high" && b.confidence !== "high")
2472
+ return a;
2473
+ if (b.confidence === "high" && a.confidence !== "high")
2474
+ return b;
2475
+ return a.token.length <= b.token.length ? a : b;
2476
+ }
2477
+ /**
2478
+ * Build a DesignBuildBrief from a DesignContract.
2479
+ *
2480
+ * - Pure: no I/O, no side effects.
2481
+ * - Never persisted or digested.
2482
+ * - Every BriefLine has non-empty groundedIn citing actual contract tokens/roles.
2483
+ * - Roles inferred from NAME only (never from value).
2484
+ * - Inferred tokens (name: null) are never cited by name; they may be cited only
2485
+ * as a count for do/avoid prose, but only if the contract has declared color
2486
+ * tokens with recognizable names to ground the line instead.
2487
+ */
2488
+ export function buildDesignBrief(contract) {
2489
+ const strength = contract.stats.strength;
2490
+ // ── Role inference ──────────────────────────────────────────────────────────
2491
+ // Walk only declared color tokens (provenance=declared, category=color, name != null).
2492
+ // Inferred tokens always have name: null; value-only matching is forbidden.
2493
+ const colorTokens = contract.tokens.filter((t) => t.category === "color" && t.name !== null);
2494
+ const roleMap = new Map();
2495
+ for (const token of colorTokens) {
2496
+ const inferred = inferRole(token.name);
2497
+ if (!inferred) {
2498
+ continue;
2499
+ }
2500
+ const candidate = {
2501
+ role: inferred.role,
2502
+ token: token.name,
2503
+ value: token.value,
2504
+ confidence: inferred.confidence,
2505
+ reason: inferred.reason,
2506
+ };
2507
+ const existing = roleMap.get(inferred.role);
2508
+ roleMap.set(inferred.role, existing ? bestCandidate(existing, candidate) : candidate);
2509
+ }
2510
+ const roles = [...roleMap.values()];
2511
+ // Helper: is a role name in this brief?
2512
+ const roleNames = new Set(roles.map((r) => r.role));
2513
+ // ── Component hints ─────────────────────────────────────────────────────────
2514
+ // Reference ONLY tokens that actually exist in the contract.
2515
+ const firstToken = (cat) => contract.tokens.find((t) => t.category === cat && t.name !== null);
2516
+ const radiusToken = firstToken("radius");
2517
+ const spaceToken = firstToken("space");
2518
+ // Border colour: sourced from the inferred border role (a color token named *border*/*outline*/*divider*).
2519
+ // There is no "border" TokenCategory — border colours live in the "color" category.
2520
+ const borderRoleColor = roleMap.get("border");
2521
+ const shadowToken = firstToken("shadow");
2522
+ const primaryRole = roleMap.get("primaryAction");
2523
+ // card hint
2524
+ const cardGuidanceLines = [];
2525
+ if (radiusToken) {
2526
+ cardGuidanceLines.push(line(`Use ${radiusToken.name} (${radiusToken.value}) for card corner radius.`, [radiusToken.name]));
2527
+ }
2528
+ if (spaceToken) {
2529
+ cardGuidanceLines.push(line(`Use ${spaceToken.name} (${spaceToken.value}) for card internal padding.`, [spaceToken.name]));
2530
+ }
2531
+ if (borderRoleColor) {
2532
+ cardGuidanceLines.push(line(`Apply ${borderRoleColor.token} for card border colour.`, [borderRoleColor.role]));
2533
+ }
2534
+ else if (shadowToken) {
2535
+ cardGuidanceLines.push(line(`Apply ${shadowToken.name} for card shadow/elevation.`, [shadowToken.name]));
2536
+ }
2537
+ const cardHint = cardGuidanceLines.length > 0
2538
+ ? { kind: "card", guidance: cardGuidanceLines, confidence: radiusToken && spaceToken ? "high" : "medium" }
2539
+ : { kind: "card", absent: true, note: "No card pattern confidently identified — reuse the host app's existing card styles." };
2540
+ // button hint
2541
+ const buttonGuidanceLines = [];
2542
+ if (primaryRole) {
2543
+ buttonGuidanceLines.push(line(`Use ${primaryRole.token} (${primaryRole.value}) as the primary button background.`, [primaryRole.role]));
2544
+ }
2545
+ if (radiusToken) {
2546
+ buttonGuidanceLines.push(line(`Apply ${radiusToken.name} (${radiusToken.value}) for button corner radius.`, [radiusToken.name]));
2547
+ }
2548
+ if (spaceToken) {
2549
+ buttonGuidanceLines.push(line(`Apply ${spaceToken.name} (${spaceToken.value}) for button horizontal padding.`, [spaceToken.name]));
2550
+ }
2551
+ const buttonHint = buttonGuidanceLines.length > 0
2552
+ ? { kind: "button", guidance: buttonGuidanceLines, confidence: primaryRole ? "high" : "medium" }
2553
+ : { kind: "button", absent: true, note: "No button pattern confidently identified — reuse the host app's existing button styles." };
2554
+ // input hint
2555
+ const inputGuidanceLines = [];
2556
+ if (borderRoleColor) {
2557
+ inputGuidanceLines.push(line(`Use ${borderRoleColor.token} for input border colour.`, [borderRoleColor.role]));
2558
+ }
2559
+ if (radiusToken) {
2560
+ inputGuidanceLines.push(line(`Apply ${radiusToken.name} (${radiusToken.value}) for input corner radius.`, [radiusToken.name]));
2561
+ }
2562
+ if (spaceToken) {
2563
+ inputGuidanceLines.push(line(`Apply ${spaceToken.name} (${spaceToken.value}) for input internal padding.`, [spaceToken.name]));
2564
+ }
2565
+ const inputHint = inputGuidanceLines.length > 0
2566
+ ? { kind: "input", guidance: inputGuidanceLines, confidence: borderRoleColor ? "high" : "medium" }
2567
+ : { kind: "input", absent: true, note: "No input pattern confidently identified — reuse the host app's existing input styles." };
2568
+ const componentHints = [cardHint, buttonHint, inputHint];
2569
+ // ── Do/Avoid lines ──────────────────────────────────────────────────────────
2570
+ // Every line MUST be grounded in tokens/roles that actually exist in this brief.
2571
+ const doLines = [];
2572
+ const avoidLines = [];
2573
+ // Only emit do/avoid lines that are grounded in actually-present tokens.
2574
+ const declaredColorTokens = colorTokens.filter((t) => t.provenance === "declared");
2575
+ const declaredSpaceTokens = contract.tokens.filter((t) => t.category === "space" && t.name !== null && t.provenance === "declared");
2576
+ const declaredRadiusTokens = contract.tokens.filter((t) => t.category === "radius" && t.name !== null && t.provenance === "declared");
2577
+ // Do: use declared color tokens
2578
+ if (declaredColorTokens.length > 0) {
2579
+ const tokenNames = declaredColorTokens.slice(0, 3).map((t) => t.name);
2580
+ doLines.push(line(`Reference declared color tokens (e.g. ${tokenNames.join(", ")}) — never introduce new hex literals.`, tokenNames));
2581
+ }
2582
+ // Do: use declared space tokens
2583
+ if (declaredSpaceTokens.length > 0) {
2584
+ const tokenNames = declaredSpaceTokens.slice(0, 3).map((t) => t.name);
2585
+ doLines.push(line(`Reference declared spacing tokens (e.g. ${tokenNames.join(", ")}) for margins, padding, and gaps.`, tokenNames));
2586
+ }
2587
+ // Do: use declared radius tokens
2588
+ if (declaredRadiusTokens.length > 0) {
2589
+ const tokenNames = declaredRadiusTokens.slice(0, 2).map((t) => t.name);
2590
+ doLines.push(line(`Use declared radius tokens (e.g. ${tokenNames.join(", ")}) for corner rounding.`, tokenNames));
2591
+ }
2592
+ // Do: use primary-role token for interactive elements
2593
+ if (primaryRole) {
2594
+ doLines.push(line(`Use the primary colour token (${primaryRole.token}) for primary interactive elements (buttons, CTAs).`, [primaryRole.role]));
2595
+ }
2596
+ // Avoid: hex literals (grounded in declared color tokens)
2597
+ if (declaredColorTokens.length > 0) {
2598
+ const tokenNames = declaredColorTokens.slice(0, 3).map((t) => t.name);
2599
+ avoidLines.push(line(`Do not introduce new hex or colour literals — use the ${declaredColorTokens.length} declared colour token(s) (e.g. ${tokenNames.join(", ")}).`, tokenNames));
2600
+ }
2601
+ // Avoid: raw spacing literals (grounded in declared space tokens)
2602
+ if (declaredSpaceTokens.length > 0) {
2603
+ const tokenNames = declaredSpaceTokens.slice(0, 2).map((t) => t.name);
2604
+ avoidLines.push(line(`Do not hardcode raw spacing values — use declared spacing tokens (e.g. ${tokenNames.join(", ")}).`, tokenNames));
2605
+ }
2606
+ // Avoid: overriding the primary colour token on interactive elements
2607
+ if (primaryRole) {
2608
+ avoidLines.push(line(`Do not override the primary colour token (${primaryRole.token}) with ad-hoc colours on interactive elements.`, [primaryRole.role]));
2609
+ }
2610
+ // ── Review notes ─────────────────────────────────────────────────────────────
2611
+ const reviewNotes = [];
2612
+ if (strength === "weak") {
2613
+ reviewNotes.push("Contract is weak — very few named tokens were found. Guidance above is minimal. Run `vise design extract --from-project` to derive a richer contract from the host project's design system, or provide a prototype.");
2614
+ }
2615
+ if (roles.length === 0) {
2616
+ reviewNotes.push("No colour roles could be inferred from token names. Role-based guidance is unavailable. Ensure tokens use recognisable names (e.g. --color-primary, --color-surface) and run `vise design extract --from-project` again.");
2617
+ }
2618
+ // Suggest missing roles using name examples, not camelCase role identifiers
2619
+ // (camelCase role names must not appear in prose to keep the weak/neutral brief JSON clean).
2620
+ if (!roleNames.has("primaryAction") && contract.stats.declared_tokens > 0) {
2621
+ reviewNotes.push("No primary action colour found — consider naming a token --color-primary (or --color-brand / --color-accent) for primary interactive elements.");
2622
+ }
2623
+ if (!roleNames.has("surface") && contract.stats.declared_tokens > 0) {
2624
+ reviewNotes.push("No surface colour found — consider naming a token --color-surface or --color-background.");
2625
+ }
2626
+ if (!roleNames.has("border") && contract.stats.declared_tokens > 0) {
2627
+ reviewNotes.push("No border colour found — consider naming a token --color-border or --color-outline.");
2628
+ }
2629
+ // ── Summary ──────────────────────────────────────────────────────────────────
2630
+ const tokenCount = contract.tokens.filter((t) => t.name !== null).length;
2631
+ const summary = roles.length > 0
2632
+ ? `Brief grounded in ${tokenCount} named token(s) and ${roles.length} inferred role(s). Contract strength: ${strength}.`
2633
+ : tokenCount > 0
2634
+ ? `Brief grounded in ${tokenCount} named token(s); no colour roles could be inferred from token names. Contract strength: ${strength}.`
2635
+ : `Contract has no named tokens — guidance is unavailable. Contract strength: ${strength}. Run \`vise design extract --from-project\` to derive tokens from the host project.`;
2636
+ return {
2637
+ summary,
2638
+ strength,
2639
+ roles,
2640
+ componentHints,
2641
+ do: doLines,
2642
+ avoid: avoidLines,
2643
+ reviewNotes,
2644
+ };
2645
+ }
2646
+ /**
2647
+ * Build outcome-specific design recipe items grounded in an existing brief.
2648
+ *
2649
+ * HARD INVARIANT: every item is grounded ONLY in roles/tokens already in the brief.
2650
+ * Items for absent roles are silently omitted. Returns `undefined` when zero items
2651
+ * can be grounded (e.g. empty brief).
2652
+ *
2653
+ * Pure — no I/O, no side effects. Generated at plan time; never persisted.
2654
+ */
2655
+ export function buildOutcomeDesignRecipe(brief, outcome) {
2656
+ const roleMap = new Map(brief.roles.map((r) => [r.role, r]));
2657
+ // Collect groundedIn entries from a given component hint (absent hints contribute nothing).
2658
+ const hintGrounding = (kind) => {
2659
+ const hint = brief.componentHints.find((h) => h.kind === kind);
2660
+ if (!hint || "absent" in hint)
2661
+ return [];
2662
+ return hint.guidance.flatMap((l) => l.groundedIn);
2663
+ };
2664
+ // Collect radius-specific grounding from the card hint by looking for guidance
2665
+ // lines that mention "corner radius" — avoids mis-citing a space token as radius.
2666
+ const cardRadiusGrounding = () => {
2667
+ const hint = brief.componentHints.find((h) => h.kind === "card");
2668
+ if (!hint || "absent" in hint)
2669
+ return [];
2670
+ return hint.guidance
2671
+ .filter((l) => l.text.includes("corner radius"))
2672
+ .flatMap((l) => l.groundedIn);
2673
+ };
2674
+ const items = [];
2675
+ if (outcome === "add-feed") {
2676
+ // Composer / action button — only when primaryAction exists.
2677
+ const primaryAction = roleMap.get("primaryAction");
2678
+ if (primaryAction) {
2679
+ items.push(line(`The post composer action button uses the primary action colour token (${primaryAction.token}).`, ["primaryAction"]));
2680
+ }
2681
+ // Post cards — only when the card hint has grounding tokens.
2682
+ const cardGrounding = hintGrounding("card");
2683
+ if (cardGrounding.length > 0) {
2684
+ items.push(line("Post cards follow the card component hint: apply the card hint tokens for corner radius, padding, and border/shadow.", cardGrounding));
2685
+ }
2686
+ // Post metadata and timestamps.
2687
+ const textSecondary = roleMap.get("textSecondary");
2688
+ if (textSecondary) {
2689
+ items.push(line(`Post metadata and timestamps use the secondary text colour token (${textSecondary.token}).`, ["textSecondary"]));
2690
+ }
2691
+ // Report / delete affordances.
2692
+ const danger = roleMap.get("danger");
2693
+ if (danger) {
2694
+ items.push(line(`Report and delete affordances use the danger colour token (${danger.token}).`, ["danger"]));
2695
+ }
2696
+ }
2697
+ else {
2698
+ // add-chat
2699
+ // Message bubbles use surface.
2700
+ const surface = roleMap.get("surface");
2701
+ if (surface) {
2702
+ const radiusGrounding = cardRadiusGrounding();
2703
+ if (radiusGrounding.length > 0) {
2704
+ items.push(line(`Message bubbles use the surface colour token (${surface.token}) with the card corner radius token applied.`, ["surface", ...radiusGrounding]));
2705
+ }
2706
+ else {
2707
+ items.push(line(`Message bubbles use the surface colour token (${surface.token}).`, ["surface"]));
2708
+ }
2709
+ }
2710
+ // Own-message vs other-message contrast — ONLY when BOTH primaryAction AND surface exist.
2711
+ const primaryAction = roleMap.get("primaryAction");
2712
+ if (primaryAction && surface) {
2713
+ items.push(line(`Own messages use the primary action colour (${primaryAction.token}) as background; other messages use the surface colour (${surface.token}).`, ["primaryAction", "surface"]));
2714
+ }
2715
+ // Timestamps.
2716
+ const textSecondary = roleMap.get("textSecondary");
2717
+ if (textSecondary) {
2718
+ items.push(line(`Message timestamps use the secondary text colour token (${textSecondary.token}).`, ["textSecondary"]));
2719
+ }
2720
+ // Composer follows the input hint tokens.
2721
+ const inputGrounding = hintGrounding("input");
2722
+ if (inputGrounding.length > 0) {
2723
+ items.push(line("The message composer follows the input component hint: apply the input hint tokens for border colour, corner radius, and padding.", inputGrounding));
2724
+ }
2725
+ // Moderation actions.
2726
+ const danger = roleMap.get("danger");
2727
+ if (danger) {
2728
+ items.push(line(`Moderation actions (report, block, mute) use the danger colour token (${danger.token}).`, ["danger"]));
2729
+ }
2730
+ }
2731
+ if (items.length === 0)
2732
+ return undefined;
2733
+ return { outcome, items };
2734
+ }
@@ -4,7 +4,7 @@ import { BROAD_SOCIAL_REGEX, DESIGN_REGEX, classifyOutcome, getOutcomeDefinition
4
4
  import { objectInput, optionalStringField, stringField, textResult } from "../types.js";
5
5
  import { capabilityChecklist } from "../capabilities.js";
6
6
  import { applicableComplianceRuleSummaries } from "./compliance.js";
7
- import { readDesignContract } from "./design.js";
7
+ import { buildDesignBrief, buildOutcomeDesignRecipe, readDesignContract } from "./design.js";
8
8
  import { sdkVersionGuidance } from "./sdkVersion.js";
9
9
  import { detectCommandSensors } from "./harness.js";
10
10
  import { inspectProject } from "./project.js";
@@ -71,8 +71,14 @@ async function buildIntegrationPlan(repoPath, request, surfacePath, answers = {}
71
71
  answers,
72
72
  });
73
73
  const definition = getOutcomeDefinition(outcome);
74
- const intake = intakeFor(ctx, definition.intakeQuestions(ctx));
74
+ // Design contract is loaded before intake so the brief can inform the fallback
75
+ // intake question (missing primary-action token) at assembly time.
75
76
  const designContract = await readDesignContract(repoRoot);
77
+ const designBrief = designContract ? buildDesignBrief(designContract) : undefined;
78
+ if (designBrief && (outcome === "add-feed" || outcome === "add-chat")) {
79
+ designBrief.outcomeRecipe = buildOutcomeDesignRecipe(designBrief, outcome) ?? undefined;
80
+ }
81
+ const intake = intakeFor(ctx, definition.intakeQuestions(ctx), outcome, designBrief);
76
82
  // Advisory SDK-version currency guidance (npm registry for TS/RN; version-agnostic
77
83
  // for native). Best-effort — degrades to greenfield "install latest + pin" if the
78
84
  // registry is unreachable. Never gates.
@@ -113,7 +119,7 @@ async function buildIntegrationPlan(repoPath, request, surfacePath, answers = {}
113
119
  sensors: sensors.map((sensor) => ({ name: sensor.name, command: sensor.command, source: sensor.source })),
114
120
  stopConditions: composeStopConditions(ctx, definition.stopConditions(ctx), inspection.surfaces, surfacePath),
115
121
  evidencePolicy: "Every implementation step must cite at least one detected file, docs page, validator rule, or required user input. If evidence is missing, stop and ask the user instead of inventing details.",
116
- designContract: designContract ? designContractGuidance(designContract) : undefined,
122
+ designContract: designContract && designBrief ? designContractGuidance(designContract, designBrief) : undefined,
117
123
  completenessChecklist: completenessChecklistFor(outcome),
118
124
  sdkVersion,
119
125
  };
@@ -137,7 +143,7 @@ function completenessChecklistFor(outcome) {
137
143
  // references `var(--x)` / maps it per platform); inferred tokens carry their
138
144
  // raw value plus a usage count and an explicit "inferred" marker so they are
139
145
  // never mistaken for authoritative brand values.
140
- function designContractGuidance(contract) {
146
+ function designContractGuidance(contract, brief) {
141
147
  const byCategory = (category) => contract.tokens
142
148
  .filter((token) => token.category === category)
143
149
  .map((token) => token.provenance === "declared" && token.name
@@ -162,6 +168,7 @@ function designContractGuidance(contract) {
162
168
  breakpoints: contract.breakpoints.map((breakpoint) => breakpoint.raw),
163
169
  attestation: `When you record a design attestation, cite this contract digest (${contract.digest}) so the generated feed can be claimed conformant to the customer's prototype.`,
164
170
  advisoryOnly: "This contract is advisory generation guidance — it adds no deterministic enforcement and never fails `vise check`.",
171
+ brief,
165
172
  };
166
173
  }
167
174
  function intentFor(request, interpretation) {
@@ -173,7 +180,7 @@ function intentFor(request, interpretation) {
173
180
  ambiguity: broadSocialRequest || designRequest ? "high" : "medium",
174
181
  };
175
182
  }
176
- function intakeFor(ctx, outcomeQuestions) {
183
+ function intakeFor(ctx, outcomeQuestions, outcome, brief) {
177
184
  const questions = [...outcomeQuestions];
178
185
  if (ctx.mentionsDesign && ctx.designSignals.length === 0 && !hasAnswer(ctx.answers, "design_source")) {
179
186
  questions.push({
@@ -194,6 +201,21 @@ function intakeFor(ctx, outcomeQuestions) {
194
201
  options: ["yes", "use another source"],
195
202
  });
196
203
  }
204
+ // Graceful-degradation fallback: when a design contract exists for a feed or chat
205
+ // outcome but no primary-action token was confidently inferred, ask the developer
206
+ // to name the correct token. Non-blocking so it doesn't stall implementation.
207
+ if (brief &&
208
+ (outcome === "add-feed" || outcome === "add-chat") &&
209
+ !brief.roles.some((r) => r.role === "primaryAction") &&
210
+ !hasAnswer(ctx.answers, "primary_action_token")) {
211
+ questions.push({
212
+ id: "primary_action_token",
213
+ question: "Which design token (or color value) should be used as the primary action color? No primary-action token was confidently identified in the design contract.",
214
+ why: "A primary action colour is needed for interactive elements (composer button, own-message bubble). Without a confident token, the agent must guess or omit it.",
215
+ required: false,
216
+ blocksImplementationWhenMissing: false,
217
+ });
218
+ }
197
219
  const remainingBlocking = questions.filter((question) => question.blocksImplementationWhenMissing).length;
198
220
  return {
199
221
  status: remainingBlocking > 0 ? "needs-clarification" : "ready",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amityco/social-plus-vise",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Skill-guided deterministic CLI for social.plus SDK integration assistance.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",
@@ -62,9 +62,10 @@
62
62
  "test:sdk-version": "npm run build && node test/run-sdk-version.mjs",
63
63
  "typecheck": "tsc -p tsconfig.json --noEmit",
64
64
  "test:e2e-package": "npm run build && node test/run-e2e-package.mjs",
65
- "validate": "npm run typecheck && npm test && npm run test:mcp && npm run test:cli && npm run test:docs && npm run test:ast && npm run test:design-extract && npm run test:capabilities && npm run test:classify && npm run test:compliance && npm run test:rule-coverage && npm run test:readme-coverage && npm run test:happy-path-clean && npm run test:fixture-symmetry && npm run test:nonui-skip && npm run test:sdk-version && npm run test:native-idioms && npm run test:grader-facts && npm run test:ground-truth && npm run test:improvements && npm run test:debug && npm run test:preflight && npm run test:e2e-package && npm run pack:check",
65
+ "validate": "npm run typecheck && npm test && npm run test:mcp && npm run test:cli && npm run test:docs && npm run test:ast && npm run test:design-extract && npm run test:design-brief && npm run test:capabilities && npm run test:classify && npm run test:compliance && npm run test:rule-coverage && npm run test:readme-coverage && npm run test:happy-path-clean && npm run test:fixture-symmetry && npm run test:nonui-skip && npm run test:sdk-version && npm run test:native-idioms && npm run test:grader-facts && npm run test:ground-truth && npm run test:improvements && npm run test:debug && npm run test:preflight && npm run test:e2e-package && npm run pack:check",
66
66
  "test:ast": "node test/run-ast-helpers.mjs",
67
67
  "test:design-extract": "npm run build && node test/run-design-extract.mjs",
68
+ "test:design-brief": "npm run build && node test/run-design-brief.mjs",
68
69
  "test:capabilities": "npm run build && node test/run-capabilities.mjs",
69
70
  "test:classify": "npm run build && node test/run-classify.mjs",
70
71
  "test:debug": "npm run build && node test/run-debug.mjs",