@amityco/social-plus-vise 1.3.0 → 1.4.1

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +51 -2
  2. package/README.md +36 -19
  3. package/dist/capabilities.js +29 -1
  4. package/dist/entryState.js +71 -0
  5. package/dist/experience.js +70 -0
  6. package/dist/explore.js +1 -1
  7. package/dist/flow.js +382 -0
  8. package/dist/humanFormat.js +25 -0
  9. package/dist/intake.js +117 -0
  10. package/dist/intelligence/placement.js +2 -1
  11. package/dist/outcomes.js +126 -28
  12. package/dist/productExpectations.js +15 -0
  13. package/dist/requestReadiness.js +99 -0
  14. package/dist/server.js +566 -19
  15. package/dist/sidecar.js +5 -0
  16. package/dist/solutionPath.js +1 -1
  17. package/dist/tools/compliance.js +752 -125
  18. package/dist/tools/creative.js +14 -12
  19. package/dist/tools/design.js +321 -11
  20. package/dist/tools/experienceCompiler.js +1 -1
  21. package/dist/tools/experienceSensors.js +1 -1
  22. package/dist/tools/harness.js +13 -0
  23. package/dist/tools/integration.js +263 -90
  24. package/dist/tools/learning.js +1 -3
  25. package/dist/tools/project.js +963 -66
  26. package/dist/tools/smoke.js +134 -0
  27. package/dist/tools/uxHarness.js +54 -6
  28. package/dist/uikitCustomization.js +3 -1
  29. package/dist/version.js +7 -3
  30. package/package.json +1 -1
  31. package/packages/intelligence/catalog/catalog.schema.json +1 -1
  32. package/packages/intelligence/catalog/experience-objects.json +2 -2
  33. package/packages/intelligence/catalog/variants.json +24 -0
  34. package/rules/event.yaml +3 -0
  35. package/rules/feed.yaml +251 -0
  36. package/rules/invitation.yaml +4 -0
  37. package/rules/live-data.yaml +110 -0
  38. package/rules/notification-tray.yaml +4 -0
  39. package/rules/poll.yaml +5 -0
  40. package/rules/sdk-lifecycle.yaml +559 -0
  41. package/rules/search.yaml +5 -0
  42. package/rules/security.yaml +12 -0
  43. package/rules/story.yaml +5 -0
  44. package/rules/user-blocking.yaml +5 -0
  45. package/skills/social-plus-vise/SKILL.md +163 -15
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { platformCapabilityAvailability } from "../capabilities.js";
6
+ import { platformFeatureGapsForObjects } from "../outcomes.js";
6
7
  import { objectInput, optionalBooleanField, optionalStringField, stringField, textResult } from "../types.js";
7
8
  import { packageVersion } from "../version.js";
8
9
  import { inspectProject } from "./project.js";
@@ -195,27 +196,27 @@ export async function buildCreativeBrief(options) {
195
196
  candidateSolutions: candidates,
196
197
  selectionGuidance: {
197
198
  mode: "agent-grounded-selection",
198
- instruction: "You (the driving agent) choose the best-fitting variant by reasoning over candidateSolutions and the request. Use each candidate's `description` and `whenToChoose` (self-describing selecting signals) to discriminate between close variants — match the signals present in the request to the variant whose `whenToChoose` they satisfy. The listed order and the `fallbackDefault` flag are advisory keyword-derived hints, not recommendations to follow — keyword matching can point at the wrong variant for off-keyword requests, so decide by semantic fit. Vise will validate (ground) your choice against the catalog; it does not infer the variant for you.",
199
+ instruction: "You (the driving agent) choose the best-fitting variant by reasoning over candidateSolutions and the request. Use each candidate's `description` and `whenToChoose` (self-describing selecting signals) to discriminate between close variants — match the signals present in the request to the variant whose `whenToChoose` they satisfy. Before accepting a variant, present an EI choice block to the user: recommended experience, 2-3 concrete request/project signals, 2-3 credible alternatives with when-to-choose/tradeoffs, expected blueprint surfaces/objects, and the no-fit option. Do not collapse EI into a one-sentence recommendation or accept before the choice is visible. The listed order and the `fallbackDefault` flag are advisory keyword-derived hints, not recommendations to follow — keyword matching can point at the wrong variant for off-keyword requests, so decide by semantic fit. Vise will validate (ground) your choice against the catalog; it does not infer the variant for you.",
199
200
  howToAccept: "vise creative accept <repoPath> --variant <candidate id> --rationale \"<one line, grounded in the request and catalog>\" --confidence high|medium|low",
200
201
  contract: {
201
202
  variant: "A candidateSolutions id chosen by best fit to the request (not by listed order or the fallbackDefault flag).",
202
203
  fallbackDefault: "One candidate carries fallbackDefault=true: the keyword top, used as the default ONLY when no agent makes a grounded selection. Do not defer to it; decide by fit.",
203
204
  rationale: "Required: one line explaining, grounded in the request and the candidate's catalog fit, why this variant. A selection without a rationale is rejected.",
204
205
  confidence: "high | medium | low. Use low when the request is vague or genuinely split between variants; low/missing confidence is recorded as a review signal, not a failure.",
205
- noFit: "If no candidate genuinely fits the request, do not force one. Record the no-fit instead: `vise creative accept <repoPath> --variant none --rationale \"<what the catalog is missing>\" [--closest <nearest variant id>]`. This writes a local catalog-gap record for human review (it does not change the catalog or upload anything); it is a signal the catalog may need a new variant.",
206
+ noFit: "If no candidate genuinely fits the request, do not force one. Record the no-fit instead: `vise creative accept <repoPath> --variant none --rationale \"<what the catalog is missing>\" --confidence high|medium|low [--closest <nearest variant id>]`. This writes a local catalog-gap record for human review (it does not change the catalog or upload anything); it is a signal the catalog may need a new variant.",
206
207
  },
207
208
  },
208
209
  questions: [
209
210
  {
210
211
  id: "preferred_solution",
211
- question: "Which creative variant best fits this request, and why?",
212
+ question: "Which EI experience should be selected after presenting the recommendation, alternatives, tradeoffs, expected blueprint surfaces, and no-fit option?",
212
213
  why: "Creative mode proposes product/UX directions. The driving agent selects the best-fitting variant and accepts it with a grounded rationale and confidence (see selectionGuidance); normal Vise planning starts only after a variant is accepted.",
213
214
  required: true,
214
215
  blocksImplementationWhenMissing: true,
215
216
  options: candidates.map((candidate) => candidate.id),
216
217
  },
217
218
  ],
218
- nextStep: "Choose the best-fitting variant for the request by reasoning over candidateSolutions (listed order is advisory), then accept it with a grounded rationale and confidence: `vise creative accept <repoPath> --variant <id> --rationale \"<why>\" --confidence high|medium|low`. Vise validates the selection against the catalog. If no candidate genuinely fits, record a no-fit instead of forcing a choice: `vise creative accept <repoPath> --variant none --rationale \"<what is missing>\"`.",
219
+ nextStep: "Present the EI choice block first: recommended experience, concrete fit signals, credible alternatives/tradeoffs, expected blueprint surfaces/objects, and no-fit option. Then choose the best-fitting variant by reasoning over candidateSolutions (listed order is advisory) and accept it with a grounded rationale and confidence: `vise creative accept <repoPath> --variant <id> --rationale \"<why>\" --confidence high|medium|low`. Vise validates the selection against the catalog. If no candidate genuinely fits, record a no-fit instead of forcing a choice: `vise creative accept <repoPath> --variant none --rationale \"<what is missing>\" --confidence high|medium|low`.",
219
220
  };
220
221
  if (options.rankingPreview === true) {
221
222
  brief.candidateRankingPreview = buildCandidateRankingPreview(brief);
@@ -242,12 +243,9 @@ export async function acceptCreativeVariant(options) {
242
243
  if (!selected) {
243
244
  throw new Error(`Creative variant "${variantId}" was not found in ${repoRelativePath(repoRoot, briefPath)}. Available variants: ${brief.candidateSolutions.map((candidate) => candidate.id).join(", ") || "(none)"}.`);
244
245
  }
245
- let grounding;
246
- if (options.rationale !== undefined || options.confidence !== undefined) {
247
- grounding = groundSelection({ variantId, rationale: options.rationale, confidence: options.confidence }, brief.candidateSolutions.map((candidate) => candidate.id));
248
- if (grounding.status === "rejected") {
249
- throw new Error(`Grounded selection for "${variantId}" was rejected: ${grounding.signals.filter((signal) => signal.severity === "error").map((signal) => signal.message).join(" ")}`);
250
- }
246
+ const grounding = groundSelection({ variantId, rationale: options.rationale, confidence: options.confidence }, brief.candidateSolutions.map((candidate) => candidate.id));
247
+ if (grounding.status === "rejected") {
248
+ throw new Error(`Grounded selection for "${variantId}" was rejected: ${grounding.signals.filter((signal) => signal.severity === "error").map((signal) => signal.message).join(" ")}`);
251
249
  }
252
250
  const selection = {
253
251
  schema_version: CREATIVE_SELECTION_SCHEMA_VERSION,
@@ -263,7 +261,7 @@ export async function acceptCreativeVariant(options) {
263
261
  },
264
262
  objective: brief.objective,
265
263
  selectedVariant: selected,
266
- ...(grounding ? { grounding } : {}),
264
+ grounding,
267
265
  implementationContext: {
268
266
  planRequest: `${selected.title}: ${brief.request}`,
269
267
  surfaceHints: creativeSurfaceHints(selected),
@@ -560,9 +558,13 @@ async function feasibilityForVariant(variant, inspection) {
560
558
  if (outcomes.length === 0) {
561
559
  notes.push("No current Vise outcome maps directly to this variant yet; treat it as exploratory product guidance.");
562
560
  }
561
+ const objectGaps = platform === "unknown" ? [] : platformFeatureGapsForObjects(variant.experienceObjectIds, platform);
562
+ for (const { gap, objects } of objectGaps) {
563
+ notes.push(`References experience object(s) ${objects.join(", ")} not available in the ${platform} SDK (${gap.feature} — available on ${gap.availableOn.join("/")}). See platformAvailability; this variant is not buildable as specified on ${platform}.`);
564
+ }
563
565
  return {
564
566
  platform,
565
- support: supportFor(outcomeResults, customObjectIds),
567
+ support: objectGaps.length > 0 ? "references-unavailable-objects" : supportFor(outcomeResults, customObjectIds),
566
568
  outcomes: outcomeResults,
567
569
  customObjectIds,
568
570
  notes,
@@ -13,6 +13,21 @@ const MAX_PROTOTYPE_FILES = 300;
13
13
  const MAX_SCAN_FILES = 2000;
14
14
  const OFF_CONTRACT_SAMPLE = 20;
15
15
  const SCAN_EXTS = new Set([".ts", ".tsx", ".js", ".jsx", ".dart", ".kt", ".java", ".swift", ".css", ".scss", ".vue", ".xml"]);
16
+ const UIKIT_JSON_DENY = new Set([
17
+ "package.json", "package-lock.json", "tsconfig.json", "jsconfig.json", "manifest.json",
18
+ "app.json", "babel.config.json", "components.json", "design-contract.json", "design-profile.json",
19
+ ]);
20
+ function isUikitThemeConfigCandidate(file) {
21
+ const base = path.basename(file).toLowerCase();
22
+ if (UIKIT_JSON_DENY.has(base) || base.startsWith("tsconfig.") || base.endsWith(".lock") || base.endsWith("-lock.json")) {
23
+ return false;
24
+ }
25
+ return base === "uikit.config.json" || base === "config.json" || /uikit/.test(base);
26
+ }
27
+ function looksLikeUikitThemeConfig(content) {
28
+ return /"preferred_theme"\s*:/.test(content) ||
29
+ (/"theme"\s*:/.test(content) && /"(?:primary_color|secondary_color|base_color|background_color)"\s*:/.test(content));
30
+ }
16
31
  const MODULE_EXTS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
17
32
  const MAX_FILE_BYTES = 2_000_000;
18
33
  const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", ".next", "out", "coverage", "vendor", ".turbo", ".cache"]);
@@ -99,6 +114,7 @@ export async function extractDesignContractFromProject(repoPath) {
99
114
  const root = path.resolve(repoPath);
100
115
  const files = await findProjectDesignFiles(root);
101
116
  const css = [];
117
+ const htmlSources = [];
102
118
  const moduleTokens = [];
103
119
  const inputs = [];
104
120
  for (const abs of files) {
@@ -119,6 +135,10 @@ export async function extractDesignContractFromProject(repoPath) {
119
135
  css.push(content);
120
136
  inputs.push(rel);
121
137
  }
138
+ else if (ext === ".html" || ext === ".htm" || ext === ".vue" || ext === ".svelte") {
139
+ htmlSources.push(content);
140
+ inputs.push(rel);
141
+ }
122
142
  else if (MODULE_EXTS.has(ext)) {
123
143
  const tokens = extractTokensFromModule(content);
124
144
  if (tokens.length > 0) {
@@ -155,6 +175,13 @@ export async function extractDesignContractFromProject(repoPath) {
155
175
  inputs.push(rel);
156
176
  }
157
177
  }
178
+ else if (ext === ".kt") {
179
+ const tokens = extractTokensFromKotlin(content);
180
+ if (tokens.length > 0) {
181
+ moduleTokens.push(...tokens);
182
+ inputs.push(rel);
183
+ }
184
+ }
158
185
  }
159
186
  inputs.sort();
160
187
  const input_digests = {};
@@ -165,7 +192,7 @@ export async function extractDesignContractFromProject(repoPath) {
165
192
  }
166
193
  catch { }
167
194
  }
168
- return buildDesignContract({ css, html: [], inputs }, { kind: "host-project", inputs, input_digests, file_count: inputs.length }, moduleTokens);
195
+ return buildDesignContract({ css, html: htmlSources, inputs }, { kind: "host-project", inputs, input_digests, file_count: inputs.length }, moduleTokens);
169
196
  }
170
197
  export async function writeDesignContract(repoPath, contract) {
171
198
  const sidecarDir = path.join(path.resolve(repoPath), "sp-vise");
@@ -188,6 +215,145 @@ export async function readDesignContract(repoPath) {
188
215
  return null;
189
216
  }
190
217
  }
218
+ export const DESIGN_PROFILE_FILENAME = "design-profile.json";
219
+ export const BRAND_PROFILE_PROTOCOL = [
220
+ "Author sp-vise/design-profile.json to ground social.plus UI in the host's BRAND, not just its tokens.",
221
+ "A capable agent produces it by examining the host app's REAL screens (screenshots / live UI), capturing what a token scrape misses:",
222
+ ' - schemaVersion: "0.1.0"',
223
+ " - essence: one sentence of the brand identity",
224
+ " - personality: 4-7 adjectives ; voice: the tone of UI microcopy this brand uses",
225
+ ' - palette: [{ role: "bg|surface|text|textDim|accent|accentText|line", hex, note }] — colors WITH ROLES (which color does what), incl. how the accent is rationed',
226
+ " - typography: { headingFamily, bodyFamily, headingStyle, scaleNote, letterSpacing }",
227
+ ' - density: "dense|comfortable|airy" + rhythm note ; elevation: "flat|soft-shadow|strong"',
228
+ " - motion: implied motion energy ; imagery: gradient-heavy | photographic | minimal-illustrative | iconographic",
229
+ " - do: 4-6 concrete on-brand directives ; avoid: 3-5 off-brand things",
230
+ "Then build each surface honoring this profile (palette ROLES, type treatment, density, shape, voice).",
231
+ "Advisory — it grounds generation; it never gates `vise check`.",
232
+ ].join("\n");
233
+ const REQUIRED_PROFILE_STRINGS = ["essence", "voice", "density", "elevation", "motion", "imagery"];
234
+ export function validateDesignProfile(raw) {
235
+ if (!raw || typeof raw !== "object") {
236
+ return null;
237
+ }
238
+ const p = raw;
239
+ if (typeof p.schemaVersion !== "string") {
240
+ return null;
241
+ }
242
+ for (const f of REQUIRED_PROFILE_STRINGS) {
243
+ if (typeof p[f] !== "string" || p[f].trim().length === 0) {
244
+ return null;
245
+ }
246
+ }
247
+ const isStrArr = (v) => Array.isArray(v) && v.every((x) => typeof x === "string");
248
+ if (!isStrArr(p.personality) || !isStrArr(p.do) || !isStrArr(p.avoid)) {
249
+ return null;
250
+ }
251
+ if (!Array.isArray(p.palette) ||
252
+ !p.palette.every((e) => e &&
253
+ typeof e === "object" &&
254
+ typeof e.role === "string" &&
255
+ typeof e.hex === "string")) {
256
+ return null;
257
+ }
258
+ if (!p.typography || typeof p.typography !== "object") {
259
+ return null;
260
+ }
261
+ return raw;
262
+ }
263
+ export async function readDesignProfile(repoPath) {
264
+ const target = path.join(path.resolve(repoPath), "sp-vise", DESIGN_PROFILE_FILENAME);
265
+ try {
266
+ return validateDesignProfile(JSON.parse(await readFile(target, "utf8")));
267
+ }
268
+ catch {
269
+ return null;
270
+ }
271
+ }
272
+ export async function writeDesignProfile(repoPath, profile) {
273
+ const sidecarDir = path.join(path.resolve(repoPath), "sp-vise");
274
+ await mkdir(sidecarDir, { recursive: true });
275
+ const target = path.join(sidecarDir, DESIGN_PROFILE_FILENAME);
276
+ await writeFile(target, `${JSON.stringify(profile, null, 2)}\n`, "utf8");
277
+ return target;
278
+ }
279
+ function hexToRgb(value) {
280
+ const h = normalizeHex(value.trim().startsWith("#") ? value.trim() : `#${value.trim()}`);
281
+ const m = /^#([0-9a-f]{6})$/.exec(h);
282
+ if (!m) {
283
+ return null;
284
+ }
285
+ const n = parseInt(m[1], 16);
286
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
287
+ }
288
+ export function contrastRatio(a, b) {
289
+ const ca = hexToRgb(a);
290
+ const cb = hexToRgb(b);
291
+ if (!ca || !cb) {
292
+ return null;
293
+ }
294
+ const lum = ([r, g, bl]) => {
295
+ const channel = (c) => {
296
+ const s = c / 255;
297
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
298
+ };
299
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(bl);
300
+ };
301
+ const la = lum(ca);
302
+ const lb = lum(cb);
303
+ const hi = Math.max(la, lb);
304
+ const lo = Math.min(la, lb);
305
+ return Math.round(((hi + 0.05) / (lo + 0.05)) * 100) / 100;
306
+ }
307
+ const DESIGN_REVIEW_STATES = [
308
+ "empty — first post / no messages yet: an inviting empty state, not a blank screen",
309
+ "loading — skeletons matching the real layout, not a bare spinner",
310
+ "error — a human message + a retry affordance, not a dead end",
311
+ ];
312
+ export function paletteContrastChecks(palette) {
313
+ const byRole = (role) => palette.find((p) => p.role.toLowerCase().trim() === role)?.hex;
314
+ const bg = byRole("bg") ?? byRole("surface") ?? byRole("background");
315
+ const text = byRole("text");
316
+ const textDim = byRole("textdim");
317
+ const accent = byRole("accent");
318
+ const accentText = byRole("accenttext");
319
+ const checks = [];
320
+ const add = (pair, fg, on, threshold, note) => {
321
+ if (!fg || !on) {
322
+ return;
323
+ }
324
+ const ratio = contrastRatio(fg, on);
325
+ if (ratio == null) {
326
+ return;
327
+ }
328
+ checks.push({ pair, ratio, threshold, passes: ratio >= threshold, ...(note ? { note } : {}) });
329
+ };
330
+ add("body text on background", text, bg, 4.5);
331
+ add("dim/secondary text on background", textDim, bg, 4.5);
332
+ add("text on accent (e.g. a button label)", accentText, accent, 4.5, "the white-on-bright trap: a label on an accent fill must clear 4.5:1");
333
+ add("accent against background", accent, bg, 3.0, "accent used for icons/borders needs 3:1; if you put TEXT in the raw accent it needs 4.5:1 — otherwise reserve the accent for fills");
334
+ return checks;
335
+ }
336
+ export function buildDesignReviewLoop(profile) {
337
+ const accessibility = paletteContrastChecks(profile.palette);
338
+ const paletteDiscipline = profile.palette
339
+ .filter((p) => p.note != null && p.note.trim().length > 0)
340
+ .map((p) => `${p.role} (${p.hex}): ${p.note.trim()}`);
341
+ return {
342
+ status: "custom-ui",
343
+ note: "Advisory generation-time self-check for the FIRST custom-UI build of this surface — render it, score it against the checks below, and iterate. vise never edits your code and this never gates `vise check`; your own design choices (and edits to sp-vise/design-profile.json) are authority. It is not an ongoing audit of work you've already tuned.",
344
+ protocol: [
345
+ "Build the surface honouring brandGrounding — palette ROLES, type treatment, density, voice — not just the hex values.",
346
+ "Render what you built (headless browser / your preview tooling) and actually look at it.",
347
+ "Score the render against the checks below: brandAvoids, paletteDiscipline, the computed accessibility ratios, statesRequired, and 'does it read as intentionally designed, not framework-default?'.",
348
+ "Do NOT eyeball contrast. For every text/icon colour you use that is NOT one of the palette pairs above (e.g. a one-off 'subtle' grey), compute it with `vise design contrast <foreground> <background>` and don't ship a pair below 4.5:1 (3:1 for large/bold text) — a faint colour that looks fine on a bright screen routinely fails.",
349
+ "Fix each failure and re-render. Iterate up to 3 rounds; stop when the checks are clean. Don't ship a round with a failing accessibility pair.",
350
+ ],
351
+ brandAvoids: profile.avoid,
352
+ paletteDiscipline,
353
+ accessibility,
354
+ statesRequired: DESIGN_REVIEW_STATES,
355
+ };
356
+ }
191
357
  export async function writeDesignPreview(repoPath, contract, referencePath) {
192
358
  const check = await runDesignCheck(repoPath).catch(() => null);
193
359
  const reference = referencePath ? await readReferenceHtml(referencePath) : null;
@@ -232,8 +398,19 @@ export const designPreviewTool = {
232
398
  written,
233
399
  digest: contract.digest,
234
400
  strength: contract.stats.strength,
401
+ ...(contract.source.self_referential ? { self_referential: true } : {}),
235
402
  referenceEmbedded: Boolean(reference),
236
- conformance: check && check.status === "advisory" ? { coverage: `${check.tokenCoverage?.referenced}/${check.tokenCoverage?.declared_total}`, off_contract: check.colorLiterals?.off_contract } : "no UI code scanned",
403
+ conformance: check && check.status === "advisory"
404
+ ? {
405
+ coverage: `${check.tokenCoverage?.referenced}/${check.tokenCoverage?.declared_total}`,
406
+ off_contract: check.colorLiterals?.off_contract,
407
+ off_contract_ratio: check.colorLiterals?.off_contract_ratio,
408
+ ...(check.colorLiterals?.palette_review ? { palette_review: check.colorLiterals.palette_review } : {}),
409
+ }
410
+ : "no UI code scanned",
411
+ ...(contract.source.self_referential
412
+ ? { self_referential_note: "Contract derived from this project's own files (--from-project): conformance is circular and is NOT a design-quality signal. For a real design target, extract from an external prototype/theme." }
413
+ : {}),
237
414
  note: "Open the HTML to visually compare the contract (and embedded reference, if HTML) against your app. A human or VLM judges the visual match — this is not an automated pixel diff.",
238
415
  where: written ? `Open ${written} in a browser.` : "Not written (write=false).",
239
416
  });
@@ -1080,6 +1257,50 @@ export const designCheckTool = {
1080
1257
  return textResult(await runDesignCheck(optionalStringField(args, "repoPath") ?? "."));
1081
1258
  },
1082
1259
  };
1260
+ export function designContrastReport(foreground, background) {
1261
+ const fg = typeof foreground === "string" ? foreground : "";
1262
+ const bg = typeof background === "string" ? background : "";
1263
+ const ratio = contrastRatio(fg, bg);
1264
+ if (ratio == null) {
1265
+ return {
1266
+ status: "invalid",
1267
+ message: `Could not parse a colour. Expected hex like #5a6477 or #abc. Got foreground="${fg}", background="${bg}".`,
1268
+ };
1269
+ }
1270
+ const verdict = ratio >= 7.0
1271
+ ? "Excellent — clears WCAG AAA for body text."
1272
+ : ratio >= 4.5
1273
+ ? "Passes WCAG AA for body text."
1274
+ : ratio >= 3.0
1275
+ ? "Passes AA only for LARGE/bold text (≥18.66px bold or ≥24px) and UI/graphics — FAILS for normal body text."
1276
+ : "FAILS WCAG AA — unreadable as text. Choose a colour with more contrast against the background.";
1277
+ return {
1278
+ status: "ok",
1279
+ foreground: fg,
1280
+ background: bg,
1281
+ ratio,
1282
+ passes: { aaNormal: ratio >= 4.5, aaLarge: ratio >= 3.0, aaaNormal: ratio >= 7.0 },
1283
+ verdict,
1284
+ note: "WCAG 2.x contrast ratio (1–21). Advisory — never gates `vise check`.",
1285
+ };
1286
+ }
1287
+ export const designContrastTool = {
1288
+ name: "design_contrast",
1289
+ description: "Compute the WCAG 2.x contrast ratio between two colours (advisory). Use it to check any text/icon colour you use that is NOT one of your brand palette's role pairs — never eyeball contrast.",
1290
+ inputSchema: {
1291
+ type: "object",
1292
+ properties: {
1293
+ foreground: { type: "string", description: "Foreground (text/icon) colour, e.g. #5a6477" },
1294
+ background: { type: "string", description: "Background colour it sits on, e.g. #0e1726" },
1295
+ },
1296
+ required: ["foreground", "background"],
1297
+ additionalProperties: false,
1298
+ },
1299
+ async call(input) {
1300
+ const args = objectInput(input);
1301
+ return textResult(designContrastReport(optionalStringField(args, "foreground"), optionalStringField(args, "background")));
1302
+ },
1303
+ };
1083
1304
  export async function runDesignCheck(repoPath) {
1084
1305
  const repoRoot = path.resolve(repoPath);
1085
1306
  const contract = await readDesignContract(repoRoot);
@@ -1091,7 +1312,12 @@ export async function runDesignCheck(repoPath) {
1091
1312
  };
1092
1313
  }
1093
1314
  const staleContract = await checkContractFreshness(repoRoot, contract);
1094
- const files = (await collectFiles(repoRoot, MAX_SCAN_FILES)).filter((file) => SCAN_EXTS.has(path.extname(file).toLowerCase()));
1315
+ const files = (await collectFiles(repoRoot, MAX_SCAN_FILES)).filter((file) => {
1316
+ const ext = path.extname(file).toLowerCase();
1317
+ if (SCAN_EXTS.has(ext))
1318
+ return true;
1319
+ return ext === ".json" && isUikitThemeConfigCandidate(file);
1320
+ });
1095
1321
  if (files.length === 0) {
1096
1322
  return { status: "no-sources", message: "No UI source files found to check against the contract.", contract: contractSummary(contract), note: ADVISORY_NOTE };
1097
1323
  }
@@ -1118,6 +1344,9 @@ export async function runDesignCheck(repoPath) {
1118
1344
  catch {
1119
1345
  continue;
1120
1346
  }
1347
+ if (path.extname(file).toLowerCase() === ".json" && !looksLikeUikitThemeConfig(content)) {
1348
+ continue;
1349
+ }
1121
1350
  scanned += 1;
1122
1351
  const rel = path.relative(repoRoot, file);
1123
1352
  const isCss = file.toLowerCase().endsWith(".css") || file.toLowerCase().endsWith(".scss");
@@ -1160,14 +1389,33 @@ export async function runDesignCheck(repoPath) {
1160
1389
  .map((token) => token.name ?? token.value);
1161
1390
  const contractTokenNames = new Set(contract.tokens.map((token) => token.name).filter((name) => Boolean(name)));
1162
1391
  const undefinedRefs = dedupeByToken(varRefs.filter((ref) => !definedVars.has(ref.token) && !contractTokenNames.has(ref.token)));
1392
+ const offContract = totalColors - onContract;
1393
+ const offContractRatio = totalColors > 0 ? offContract / totalColors : 0;
1394
+ const paletteReview = totalColors >= 10 && offContractRatio >= 0.5
1395
+ ? `${offContract} of ${totalColors} color literals (${Math.round(offContractRatio * 100)}%) are off-contract — the palette is mostly ad-hoc hardcoded colors, not token-driven. Worth a human's eyes on visual coherence.`
1396
+ : undefined;
1397
+ const selfRefNote = contract.source.self_referential
1398
+ ? `Contract is SELF-REFERENTIAL (derived from this project's own files via --from-project): token-coverage measures internal reuse, not conformance to an external/authoritative design — not a design-quality signal. For that, extract from a real prototype/theme. `
1399
+ : "";
1400
+ const profile = await readDesignProfile(repoRoot);
1401
+ const palettePairs = profile ? paletteContrastChecks(profile.palette) : [];
1402
+ const accessibility = palettePairs.length > 0
1403
+ ? {
1404
+ palettePairs,
1405
+ failing: palettePairs.filter((c) => !c.passes).length,
1406
+ note: "Computed WCAG contrast for your brand palette's role pairs (advisory, never gates). A failing pair means that combination is unreadable — fix the palette or don't use it for text. Don't eyeball one-off colours: run `vise design contrast <fg> <bg>`.",
1407
+ }
1408
+ : undefined;
1163
1409
  return {
1164
1410
  status: "advisory",
1165
- message: `Checked ${scanned} source file(s) against design contract ${contract.digest}. ` +
1411
+ message: selfRefNote +
1412
+ `Checked ${scanned} source file(s) against design contract ${contract.digest}. ` +
1413
+ (paletteReview ? `${paletteReview} ` : "") +
1166
1414
  `${referencedTokens.length}/${declaredTokens.length} declared tokens referenced` +
1167
1415
  (seededOnlyTokens.length > 0
1168
1416
  ? ` (${referencedTokens.length - seededOnlyTokens.length} used in your code, ${seededOnlyTokens.length} only in the Vise-seeded ${SP_TOKENS_PATH} — apply them to count)`
1169
1417
  : "") +
1170
- `; ${totalColors - onContract} of ${totalColors} color literal(s) are off-contract (review hints)` +
1418
+ `; ${offContract} of ${totalColors} color literal(s) are off-contract (review hints)` +
1171
1419
  (undefinedRefs.length > 0 ? `; ${undefinedRefs.length} undefined token reference(s) (likely broken styles)` : "") +
1172
1420
  ".",
1173
1421
  contract: contractSummary(contract),
@@ -1183,13 +1431,16 @@ export async function runDesignCheck(repoPath) {
1183
1431
  scanned_files: scanned,
1184
1432
  total: totalColors,
1185
1433
  on_contract: onContract,
1186
- off_contract: totalColors - onContract,
1434
+ off_contract: offContract,
1435
+ off_contract_ratio: Math.round(offContractRatio * 100) / 100,
1436
+ ...(paletteReview ? { palette_review: paletteReview } : {}),
1187
1437
  sample: colorSample,
1188
1438
  },
1189
1439
  undefinedTokenRefs: {
1190
1440
  count: undefinedRefs.length,
1191
1441
  sample: undefinedRefs.slice(0, OFF_CONTRACT_SAMPLE),
1192
1442
  },
1443
+ ...(accessibility ? { accessibility } : {}),
1193
1444
  ...(staleContract ? { staleContract } : {}),
1194
1445
  note: ADVISORY_NOTE,
1195
1446
  };
@@ -1252,6 +1503,7 @@ function contractSummary(contract) {
1252
1503
  strength: contract.stats.strength,
1253
1504
  declared_tokens: contract.stats.declared_tokens,
1254
1505
  inferred_tokens: contract.stats.inferred_tokens,
1506
+ ...(contract.source.self_referential ? { self_referential: true } : {}),
1255
1507
  };
1256
1508
  }
1257
1509
  function tokenKey(token) {
@@ -1409,6 +1661,12 @@ async function isDesignFile(full) {
1409
1661
  if (ext === ".swift") {
1410
1662
  return /(theme|color|palette|style|appearance|design)/.test(base);
1411
1663
  }
1664
+ if (ext === ".kt") {
1665
+ return /(theme|color|token|palette)/.test(base);
1666
+ }
1667
+ if (ext === ".html" || ext === ".htm" || ext === ".vue" || ext === ".svelte") {
1668
+ return htmlFileDeclaresDesignTokens(full);
1669
+ }
1412
1670
  if (MODULE_EXTS.has(ext)) {
1413
1671
  if (/^tailwind\.config\.(js|ts|cjs|mjs)$/.test(base)) {
1414
1672
  return true;
@@ -1433,6 +1691,20 @@ async function cssFileDeclaresDesignTokens(full) {
1433
1691
  return false;
1434
1692
  }
1435
1693
  }
1694
+ async function htmlFileDeclaresDesignTokens(full) {
1695
+ try {
1696
+ const info = await stat(full);
1697
+ if (info.size > MAX_FILE_BYTES) {
1698
+ return false;
1699
+ }
1700
+ const content = await readFile(full, "utf8");
1701
+ const styleCss = extractStyleBlocks(content).join("\n");
1702
+ return /(^|[{\s;])--[a-zA-Z0-9_-]+\s*:\s*[^;{}]+[;}]/.test(stripCssComments(styleCss));
1703
+ }
1704
+ catch {
1705
+ return false;
1706
+ }
1707
+ }
1436
1708
  export function buildDesignContract(sources, sourceMeta, extraDeclaredTokens = []) {
1437
1709
  const cssText = [...sources.css, ...sources.html.flatMap(extractStyleBlocks)].join("\n");
1438
1710
  const strippedCss = stripCssComments(cssText);
@@ -1505,11 +1777,12 @@ export function buildDesignContract(sources, sourceMeta, extraDeclaredTokens = [
1505
1777
  const components = inferComponents(sources.html, strippedCss);
1506
1778
  const declaredCount = tokens.filter((token) => token.provenance === "declared").length;
1507
1779
  const inferredCount = tokens.filter((token) => token.provenance === "inferred").length;
1780
+ const selfReferential = sourceMeta.kind === "host-project";
1508
1781
  const contract = {
1509
1782
  schema_version: DESIGN_CONTRACT_SCHEMA_VERSION,
1510
1783
  vise_version: packageVersion,
1511
1784
  foundry_version: packageVersion,
1512
- source: sourceMeta,
1785
+ source: selfReferential ? { ...sourceMeta, self_referential: true } : sourceMeta,
1513
1786
  digest: "",
1514
1787
  tokens,
1515
1788
  breakpoints,
@@ -1518,15 +1791,15 @@ export function buildDesignContract(sources, sourceMeta, extraDeclaredTokens = [
1518
1791
  declared_tokens: declaredCount,
1519
1792
  inferred_tokens: inferredCount,
1520
1793
  advisory_components: components.length,
1521
- strength: gradeStrength(declaredCount, inferredCount),
1794
+ strength: gradeStrength(declaredCount, inferredCount, selfReferential),
1522
1795
  },
1523
1796
  };
1524
1797
  contract.digest = digestContract(contract);
1525
1798
  return contract;
1526
1799
  }
1527
- function gradeStrength(declared, inferred) {
1800
+ function gradeStrength(declared, inferred, selfReferential = false) {
1528
1801
  if (declared >= 6) {
1529
- return "strong";
1802
+ return selfReferential ? "partial" : "strong";
1530
1803
  }
1531
1804
  if (declared >= 1 || inferred >= 4) {
1532
1805
  return "partial";
@@ -2038,6 +2311,22 @@ export function extractTokensFromDart(content) {
2038
2311
  }
2039
2312
  return tokens;
2040
2313
  }
2314
+ export function extractTokensFromKotlin(content) {
2315
+ const tokens = [];
2316
+ const seen = new Set();
2317
+ const pattern = /(\w+)\s*[:=]\s*(?:const\s+)?Color\(\s*0x([0-9a-fA-F]{8})\s*\)/g;
2318
+ let match;
2319
+ while ((match = pattern.exec(content)) !== null) {
2320
+ const name = match[1];
2321
+ const value = `#${match[2].slice(2).toLowerCase()}`;
2322
+ const key = `color::${name}::${value}`;
2323
+ if (!seen.has(key)) {
2324
+ seen.add(key);
2325
+ tokens.push({ category: "color", name, value, provenance: "declared", uses: 0 });
2326
+ }
2327
+ }
2328
+ return tokens;
2329
+ }
2041
2330
  export function extractTokensFromColorset(contentsJson, name) {
2042
2331
  let parsed;
2043
2332
  try {
@@ -2300,7 +2589,7 @@ function bestCandidate(a, b) {
2300
2589
  return b;
2301
2590
  return a.token.length <= b.token.length ? a : b;
2302
2591
  }
2303
- export function buildDesignBrief(contract) {
2592
+ export function buildDesignBrief(contract, profile) {
2304
2593
  const strength = contract.stats.strength;
2305
2594
  const colorTokens = contract.tokens.filter((t) => t.category === "color" && t.name !== null);
2306
2595
  const roleMap = new Map();
@@ -2478,6 +2767,26 @@ export function buildDesignBrief(contract) {
2478
2767
  : tokenCount > 0
2479
2768
  ? `Brief grounded in ${tokenCount} named token(s); no colour roles could be inferred from token names. Contract strength: ${strength}. roles/hints cover ${briefCoveredCount} of ${totalDeclaredTokens} declared tokens — consult the full set.`
2480
2769
  : `Contract has no named tokens — guidance is unavailable. Contract strength: ${strength}. Run \`vise design extract --from-project\` to derive tokens from the host project.`;
2770
+ const brandGrounding = profile
2771
+ ? {
2772
+ status: "active",
2773
+ essence: profile.essence,
2774
+ personality: profile.personality,
2775
+ voice: profile.voice,
2776
+ palette: profile.palette,
2777
+ typography: profile.typography,
2778
+ density: profile.density,
2779
+ elevation: profile.elevation,
2780
+ motion: profile.motion,
2781
+ imagery: profile.imagery,
2782
+ do: profile.do,
2783
+ avoid: profile.avoid,
2784
+ source: profile.capturedFrom ?? "sp-vise/design-profile.json",
2785
+ }
2786
+ : {
2787
+ status: "absent",
2788
+ howTo: "These are tokens only. For richer brand grounding (personality, type system, density, voice, do/avoid), author sp-vise/design-profile.json from the host's real screens, then honor it per surface. See the 'Brand profile' subsection of the social.plus design skill for the schema.",
2789
+ };
2481
2790
  return {
2482
2791
  summary,
2483
2792
  strength,
@@ -2486,6 +2795,7 @@ export function buildDesignBrief(contract) {
2486
2795
  do: doLines,
2487
2796
  avoid: avoidLines,
2488
2797
  reviewNotes,
2798
+ brandGrounding,
2489
2799
  };
2490
2800
  }
2491
2801
  export function buildOutcomeDesignRecipe(brief, outcome) {
@@ -54,7 +54,7 @@ export async function compileExperience(options) {
54
54
  const repoRoot = path.resolve(options.repoPath);
55
55
  const selection = await readCreativeSelection(repoRoot);
56
56
  if (!selection) {
57
- throw new Error("Experience Compiler requires an accepted creative selection. Run `vise creative . --request \"...\"`, then `vise creative accept . --variant <id>` first.");
57
+ throw new Error("Experience Compiler requires an accepted creative selection. Run `vise creative . --request \"...\"`, then `vise creative accept . --variant <id> --rationale \"<why>\" --confidence high` first.");
58
58
  }
59
59
  const request = options.request ?? selection.source.request;
60
60
  const inspection = await inspectProject(repoRoot, options.surfacePath);
@@ -266,7 +266,7 @@ function businessSensors(uxHarness) {
266
266
  title: "Selected variant maps goals to patterns, objects, and UX",
267
267
  status: hasMapping ? "passed" : selected ? "needs-review" : "needs-setup",
268
268
  source: "ux-harness",
269
- command: "vise creative accept . --variant <id>",
269
+ command: "vise creative accept . --variant <id> --rationale \"<why>\" --confidence high",
270
270
  reason: hasMapping
271
271
  ? `Selected variant "${selected?.title}" carries business goals, solution patterns, experience objects, and UX patterns.`
272
272
  : selected
@@ -305,6 +305,19 @@ async function packageJsonSensors(root) {
305
305
  ...(skip ? { skip } : {}),
306
306
  };
307
307
  });
308
+ if (hasPackageDependency(parsed, "@amityco/ts-sdk-react-native")) {
309
+ sensors.push({
310
+ name: "React Native SDK import smoke",
311
+ command: [
312
+ "node",
313
+ "-e",
314
+ "require.resolve('@amityco/ts-sdk-react-native'); console.log('Vise smoke: @amityco/ts-sdk-react-native resolves')",
315
+ ],
316
+ timing: "after-change",
317
+ purpose: "Verify the social.plus React Native SDK resolves from the host project runtime environment.",
318
+ source: "package.json",
319
+ });
320
+ }
308
321
  if (hasPackageDependency(parsed, "@amityco/ts-sdk")) {
309
322
  sensors.push({
310
323
  name: "TypeScript SDK import smoke",