diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index c3aac0c..5730b21 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -22,8 +22,8 @@ }, { "name": "canvas-authoring", - "description": "Build canvas apps with the native create-canvas skill and a toolkit setup companion.", - "version": "0.1.2", + "description": "Create canvas apps with guided setup and reusable UI components from the Microsoft Canvas Toolkit. Includes a background extension used only for skill-usage telemetry.", + "version": "0.1.3", "source": "plugins/canvas-authoring" }, { diff --git a/docs/canvas-authoring/0.1.3/SHA256SUMS b/docs/canvas-authoring/0.1.3/SHA256SUMS new file mode 100644 index 0000000..a4c5bc4 --- /dev/null +++ b/docs/canvas-authoring/0.1.3/SHA256SUMS @@ -0,0 +1,28 @@ +584556ed1ae6c8fe12a95c0a11d98a3729ca6a5cffc9c6422188060138e5abe2 README.md +82b3d5b2bed3ef364a0eec224482a0e76b580d90c8ed34942868d294a09364e2 com.github.copilot/extensions/canvas-authoring/extension.mjs +acb58c635a87b78b1c5034533b32e2b1d2532956e5bf511842c3eaa8fc6cb315 plugin.json +5939bfec3d332abe5ee6972d3490a8e64b8e06e48a7f4920a10f96af69751a4e skills/create-canvas-app/SKILL.md +c28db43ba16888d8e0afab71ca2bd83dcb664551a096a44a5b34e40dc276dd88 skills/create-canvas-app/references/toolkit.md +d25deed12d803f6c973597348627b1939e2d19bbc28fda83c93ea0cde44702da skills/create-canvas-app/references/toolkit/LICENSE +63b940887b422af5da156b3ece522ec90afd26bd9e5b8271aad2fe60735ed814 skills/create-canvas-app/references/toolkit/README.md +b22b317cd93b5fb40636fbaccba2750501faf41b54bcf9c6bb971c5355c9e23b skills/create-canvas-app/references/toolkit/auth.md +70d8d6683e31c01ce8538d339f676901ac1332710dc19ff8afbfa443b0ae399b skills/create-canvas-app/references/toolkit/examples/README.md +18ea90a17fb7d39104f343b3a53079dfed536bdd9a6dafc71321de5158be61bb skills/create-canvas-app/references/toolkit/examples/custom-fetch-client.mjs +3d81ea6de1551da83f008251f204e06e736e9655aed450765b4f416a834d1eb8 skills/create-canvas-app/references/toolkit/examples/resource-groups.mjs +5809596d28119f393975235a44b5784d95a82354eb56d84c2adcbba0f2ab34ce skills/create-canvas-app/references/toolkit/examples/run-read-only.mjs +e9adf9797984f6b23f2f7b0807898a551f2ca5ab122f5f644033e705e2efa1cc skills/create-canvas-app/references/toolkit/examples/sdk-client.mjs +2b1eef605c99ff364839edf325d824ae1fe300fb54cf7b5a84ebd8f252e83568 skills/create-canvas-app/references/toolkit/provenance.json +90d339293305a4131381db2170bda0b68fd3dba63a78259f21d373b096bfae5e skills/create-canvas-app/references/toolkit/quickstart.md +c1c94cf568e5fdef3013b81aeb63e9b7aa1d2efa983f19677f3642f7ebacf0a9 skills/create-canvas-app/references/toolkit/ui-components.md +721004c52d2ce05d5da548d98dbc581da2198840b4e481deed3b0ddad21aad8d skills/create-canvas-app/scripts/setup-toolkit.mjs +79d3ad45fb767fba8e873a6000c55013d7df2d802541d1af6060ef986927f080 skills/create-canvas-app/templates/azure-resource-groups/README.md +67451cd55721f065083817d8a6cf0e1068b54a828e000cf00627fe25fe44ca6f skills/create-canvas-app/templates/azure-resource-groups/package.json +3bcf34bcc5dd31561430a860e289e35a641d7e328e7cf20b08ee4fc819320c15 skills/create-canvas-app/templates/azure-resource-groups/scripts/check-entry.mjs +94397d5f1426d39ba319d5f03dfd6c03b8c9e2837cb04438c897f687eeecdd9a skills/create-canvas-app/templates/azure-resource-groups/scripts/smoke.mjs +fc58f8272cb7a019d6731e1b9bc28fd91a65202ae5bfcb11da00c6f895216d3d skills/create-canvas-app/templates/azure-resource-groups/src/browser/app.css +52a579c1faee054e5efe31594808fa7f67f013eb87614d284648fd20ea36899a skills/create-canvas-app/templates/azure-resource-groups/src/browser/app.mjs +7de54eac2b6b0801abfdd0512c96c396226553a50fbc514285de8d46021ad6a5 skills/create-canvas-app/templates/azure-resource-groups/src/browser/index.html +16dfd1d2bd48e3563e84597337b97e87d6ed20960336ac7249fa131ed641dd54 skills/create-canvas-app/templates/azure-resource-groups/src/canvas.mjs +62b66ccbf5d74cd8b59c53a3a5ba74772e58f51893a0199c92e36c05db127b12 skills/create-canvas-app/templates/azure-resource-groups/src/domain.mjs +f8f66cbd63fa3a07427676085b64e2684f60398e1459e7a3927b2104d8fb8186 skills/create-canvas-app/templates/azure-resource-groups/src/fixture.mjs +55e688f04faa2d6bf4587f6a5f9bdd445494c2e5b13495eef58ef2004497daa4 skills/create-canvas-app/templates/azure-resource-groups/tests/app.test.mjs diff --git a/plugins/canvas-authoring/README.md b/plugins/canvas-authoring/README.md index cb5be4f..6d888fd 100644 --- a/plugins/canvas-authoring/README.md +++ b/plugins/canvas-authoring/README.md @@ -1,9 +1,13 @@ # Canvas authoring plugin -Create a canvas app with the Microsoft Canvas Toolkit. This **skill-only -companion** gives the host's native `create-canvas` workflow toolkit setup -and a counter or read-only Azure resource-group starter; installing it does -not open a canvas. +Create a canvas app with the Microsoft Canvas Toolkit. + +## What you can do + +- Turn an app idea into a canvas project with guided setup. +- Build interfaces with reusable components that match Copilot's theme. +- Share actions and state between the canvas and your agent. +- Connect to Azure with authentication, subscription selection and read-only resource access. ## Install @@ -37,12 +41,6 @@ groups. Use the native create-canvas skill and the create-canvas-app companion's Azure starter.** Choose a compatible toolkit package and project location when prompted. For a non-Azure example, ask for the counter starter. -## What you can do - -- Generate a native canvas scaffold with a working counter starter. -- Start a read-only Azure resource-group viewer with explicit subscription choice. -- Build and smoke-test the generated app before installing its complete output. - ## Prompts to try > Use the native create-canvas skill and the create-canvas-app companion to build a counter canvas. @@ -61,11 +59,16 @@ location when prompted. For a non-Azure example, ask for the counter starter. ## Usage data -The plugin counts how often its `create-canvas-app` skill is invoked. Each -count records the skill name, how it was invoked, the plugin name and version, -and a timestamp. Usage telemetry may include a pseudonymous account identifier -to understand usage over time; it is retained for up to 30 days. The plugin -does not send prompts, project files or source code. +The plugin includes a background extension only for skill-usage telemetry. +It does not add a canvas, tools or hooks. + +Usage telemetry is enabled by default to help improve this plugin. It sends +basic skill-usage data and, when available, your numeric GitHub user ID to the +[usage metrics service](https://canvas-metrics-pilot-0b8944-add3hsghhddcbxe9.b01.azurefd.net/api/events). +Prompts, tool inputs/outputs and credentials are excluded. + +You can [disable usage telemetry](https://github.com/microsoft/azure-dev-tools#disable-usage-telemetry). +Copilot's own telemetry is separate. ## Create an app diff --git a/plugins/canvas-authoring/com.github.copilot/extensions/canvas-authoring/extension.mjs b/plugins/canvas-authoring/com.github.copilot/extensions/canvas-authoring/extension.mjs index 1f7815b..8b9160c 100644 --- a/plugins/canvas-authoring/com.github.copilot/extensions/canvas-authoring/extension.mjs +++ b/plugins/canvas-authoring/com.github.copilot/extensions/canvas-authoring/extension.mjs @@ -1,3 +1,3 @@ import { createRequire as __canvasCreateRequire } from "node:module"; const require = __canvasCreateRequire(import.meta.url); -import{joinSession}from"@github/copilot-sdk/extension";import{existsSync}from"node:fs";import path2 from"node:path";import{fileURLToPath}from"node:url";import{randomUUID}from"node:crypto";import{execFile}from"node:child_process";var ID=/^[a-z][a-z0-9_.-]{0,79}$/;var VERSION=/^[a-zA-Z0-9][a-zA-Z0-9.+_-]{0,39}$/;var SOURCES=new Set(["model","panel","system","unknown"]);var CONTROL_TYPES=new Set(["button","link","select","input","textarea","details","other"]);var INTERACTIONS=new Set(["click","change","submit"]);var FAILURE_CODES=Object.freeze({succeeded:"",failed:"action_error",rejected:"action_rejected",cancelled:"action_cancelled"});var RESERVED=new Set(["__proto__","constructor","prototype"]);var USAGE_ENVELOPE_SCHEMA_VERSION=2;var DEFAULT_REQUEST_TIMEOUT_MS=1e4;var PUBLIC_CANVAS_USAGE_SERVICE=Object.freeze({serviceId:"public-canvas-product-usage",schemaVersion:USAGE_ENVELOPE_SCHEMA_VERSION,dataClassification:"pseudonymous_product_usage",endpoint:"https://canvas-metrics-pilot-0b8944-add3hsghhddcbxe9.b01.azurefd.net/api/events"});var PUBLIC_CANVAS_USAGE_ENDPOINT=PUBLIC_CANVAS_USAGE_SERVICE.endpoint;var GITHUB_USER_ID=/^[1-9][0-9]{0,19}$/;function githubCliEnvironment(source){const env={...source};for(const key of Object.keys(env)){if(["GH_TOKEN","GITHUB_TOKEN"].includes(key.toUpperCase()))delete env[key]}return env}function runFile(command,args,options,execFileImpl){return new Promise((resolve,reject)=>{execFileImpl(command,args,options,(error,stdout)=>{if(error)reject(error);else resolve(stdout)})})}async function resolveGitHubCliUserId({signal,env=process.env,execFileImpl=execFile}={}){if(typeof execFileImpl!=="function")throw new TypeError("GitHub CLI executor must be a function.");let stdout;try{stdout=await runFile("gh",["api","--hostname","github.com","user","--jq",".id"],{env:githubCliEnvironment(env),signal,timeout:5e3,maxBuffer:1024,windowsHide:true},execFileImpl)}catch{throw new Error("GitHub identity is unavailable.")}const githubUserId=String(stdout).trim();if(!GITHUB_USER_ID.test(githubUserId))throw new Error("GitHub identity is unavailable.");return githubUserId}function identifier(value,label,pattern=ID){if(typeof value!=="string"||!pattern.test(value)||RESERVED.has(value)){throw new TypeError(`${label} must be a bounded, code-defined identifier.`)}return value}function record(value,label){if(!value||typeof value!=="object"||![Object.prototype,null].includes(Object.getPrototypeOf(value))){throw new TypeError(`${label} must be a plain record.`)}return value}function integer(value,min,max,label){if(!Number.isSafeInteger(value)||valuemax){throw new TypeError(`${label} must be an integer from ${min} to ${max}.`)}return value}function endpoint(value){try{const url=new URL(value);if(url.protocol==="https:"&&!url.username&&!url.password&&!url.search&&!url.hash)return url.href}catch{}throw new TypeError("Telemetry endpoint must be credential-free HTTPS without a query or fragment.")}function createUsageMetrics(options,skillContract){const{contract,canvasVersion,releaseChannel="development",enabled=false,endpoint:destination,audience,getAccessToken,transport,fetchImpl=globalThis.fetch,getGitHubUserId,now=Date.now,randomId=randomUUID,onDiagnostic,batchSize=20,maxQueueSize=100,flushIntervalMs=1e3,requestTimeoutMs=DEFAULT_REQUEST_TIMEOUT_MS,closeTimeoutMs=DEFAULT_REQUEST_TIMEOUT_MS}=options;if(contract!==void 0){record(contract,"contract");if(["canvasId","host","actionUsage","controls"].some(key=>Object.hasOwn(options,key))){throw new TypeError("Contract identity and registries cannot be overridden.")}}const canvasId=contract?.canvasId??options.canvasId;const host=contract?.host??options.host??"copilot_app";const actionUsage=contract?.actions??options.actionUsage??{};const controls=contract?.controls??options.controls??{};identifier(canvasId,"canvasId");identifier(canvasVersion,"canvasVersion",VERSION);if(!["copilot_app","mcp_app"].includes(host))throw new TypeError("Unsupported telemetry host.");if(contract!==void 0){if(!Array.isArray(contract.versions)||!contract.versions.length||contract.versions.some(version=>typeof version!=="string"||!VERSION.test(version))){throw new TypeError("Contract versions must be bounded version identifiers.")}if(!contract.versions.includes(canvasVersion)){throw new TypeError("Canvas version is not registered by the telemetry contract.")}}if(!["development","internal","preview","stable"].includes(releaseChannel)){throw new TypeError("Unsupported telemetry release channel.")}if(typeof enabled!=="boolean")throw new TypeError("enabled must be a boolean.");const url=destination===void 0&&(!enabled||transport!==void 0)?void 0:endpoint(destination);for(const[name,fn]of Object.entries({now,randomId})){if(typeof fn!=="function")throw new TypeError(`${name} must be a function.`)}if(transport!==void 0&&typeof transport!=="function")throw new TypeError("transport must be a function.");if(getAccessToken!==void 0&&typeof getAccessToken!=="function")throw new TypeError("getAccessToken must be a function.");if(enabled&&!transport&&typeof fetchImpl!=="function")throw new TypeError("fetchImpl must be a function.");if(getGitHubUserId!==void 0&&typeof getGitHubUserId!=="function"){throw new TypeError("getGitHubUserId must be a function.")}if(audience!==void 0&&(typeof audience!=="string"||!audience.trim()||audience.length>512||/[\r\n]/.test(audience)))throw new TypeError("audience must be a bounded nonempty string.");if(onDiagnostic!==void 0&&typeof onDiagnostic!=="function")throw new TypeError("onDiagnostic must be a function.");integer(batchSize,1,100,"batchSize");integer(maxQueueSize,batchSize,1e3,"maxQueueSize");integer(flushIntervalMs,10,6e4,"flushIntervalMs");integer(requestTimeoutMs,10,3e4,"requestTimeoutMs");integer(closeTimeoutMs,10,3e4,"closeTimeoutMs");const actions=new Map(Object.entries(record(actionUsage,"actionUsage")).map(([name,entry])=>{identifier(name,"action name");record(entry,"action usage");identifier(entry.featureId,"featureId");identifier(entry.featureArea,"featureArea");if(!["intentional","automatic","excluded"].includes(entry.usageClass)||typeof entry.mutates!=="boolean"){throw new TypeError("Action usage requires a supported usageClass and boolean mutates.")}return[name,Object.freeze({featureId:entry.featureId,featureArea:entry.featureArea,usageClass:entry.usageClass,mutates:entry.mutates})]}));const controlRegistry=new Map(Object.entries(record(controls,"controls")).map(([id,type])=>{identifier(id,"control ID");if(!CONTROL_TYPES.has(type))throw new TypeError("Unsupported telemetry control type.");return[id,type]}));const queue=[];const stats={delivered:0,dropped:0,invalid:0};const reported=new Set;const shutdown2=new AbortController;let timer,activeFlush,activeController,closePromise;let inFlight=0,closing=false;let resolvedGitHubUserId;function diagnose(code){if(reported.has(code))return;reported.add(code);const warn2=message=>{try{process.emitWarning(message,{code:"CANVAS_USAGE_METRICS"})}catch{}};if(onDiagnostic){const reportFailure=()=>warn2("Canvas telemetry diagnostic callback failed.");try{Promise.resolve(onDiagnostic(Object.freeze({code}))).catch(reportFailure)}catch{reportFailure()}}else{warn2(`Canvas telemetry: ${code}.`)}}function invalid(){stats.invalid+=1;diagnose("invalid_event");return false}async function githubIdentity(signal){if(resolvedGitHubUserId)return resolvedGitHubUserId;try{const value=await getGitHubUserId(signal);if(signal.aborted)return;if(typeof value==="string"&&GITHUB_USER_ID.test(value)){resolvedGitHubUserId=value;return value}}catch{}diagnose("identity_unavailable")}function schedule(){if(closing||timer||activeFlush||!queue.length)return;timer=setTimeout(()=>{timer=void 0;void flush()},queue.length>=batchSize?0:flushIntervalMs);timer.unref?.()}function enqueue(eventName,fields={}){if(!enabled||closing)return false;try{const eventId=randomId();if(typeof eventId!=="string"||!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(eventId)){return invalid()}const event=Object.freeze({...skillContract?{schemaVersion:2,pluginName:skillContract.pluginName,pluginVersion:canvasVersion}:{schemaVersion:1,canvasId,canvasVersion},host,releaseChannel,timestamp:new Date(now()).toISOString(),eventId,eventName,invocationSource:"unknown",...fields});const overflow=queue.length===maxQueueSize;if(overflow){queue.shift();stats.dropped+=1}queue.push(event);if(overflow)diagnose("queue_overflow");if(queue.length>=batchSize&&!activeFlush)void flush();else schedule();return true}catch{stats.dropped+=1;diagnose("event_construction_failed");return false}}async function deliver(batch){const controller=new AbortController;activeController=controller;const stop=()=>controller.abort(shutdown2.signal.reason);const deadline=setTimeout(()=>controller.abort("delivery_timeout"),requestTimeoutMs);deadline.unref?.();shutdown2.signal.addEventListener("abort",stop,{once:true});if(shutdown2.signal.aborted)stop();let onAbort;const aborted=new Promise(resolve=>{onAbort=()=>resolve(controller.signal.reason);controller.signal.addEventListener("abort",onAbort,{once:true});if(controller.signal.aborted)onAbort()});const request=Promise.resolve().then(async()=>{if(controller.signal.aborted)return controller.signal.reason;if(transport){await transport(Object.freeze(batch),{signal:controller.signal});return}const headers={"Content-Type":"application/json"};if(getAccessToken){const credential=await getAccessToken(audience,controller.signal);if(controller.signal.aborted)return controller.signal.reason;const token=typeof credential==="string"?credential:credential?.accessToken;if(typeof token!=="string"||!token.trim()||/[\r\n]/.test(token))return"delivery_failed";headers.Authorization=`Bearer ${token}`}const githubUserId=getGitHubUserId?await githubIdentity(controller.signal):void 0;if(getGitHubUserId){if(controller.signal.aborted)return controller.signal.reason}const response=await fetchImpl(url,{method:"POST",headers,body:JSON.stringify({schemaVersion:USAGE_ENVELOPE_SCHEMA_VERSION,...githubUserId?{githubUserId}:{},events:batch}),signal:controller.signal,redirect:"error",credentials:"omit"});const accepted=response?.ok;await response?.body?.cancel();return accepted?void 0:"delivery_rejected"}).catch(()=>"delivery_failed");try{const failure=await Promise.race([request,aborted]);if(failure)diagnose(failure);return{delivered:failure?0:batch.length,dropped:failure?batch.length:0}}finally{clearTimeout(deadline);shutdown2.signal.removeEventListener("abort",stop);controller.signal.removeEventListener("abort",onAbort);if(activeController===controller)activeController=void 0}}function flush(){if(activeFlush)return activeFlush;clearTimeout(timer);timer=void 0;if(!queue.length)return Promise.resolve({delivered:0,dropped:0});const batch=queue.splice(0,batchSize);inFlight=batch.length;activeFlush=deliver(batch).then(result=>{stats.delivered+=result.delivered;stats.dropped+=result.dropped;return result}).finally(()=>{inFlight=0;activeFlush=void 0;schedule()});return activeFlush}function feature(actionName,invocationSource){const usage2=actions.get(actionName);if(!usage2||!SOURCES.has(invocationSource))return invalid();if(usage2.usageClass==="excluded")return false;return{...usage2,actionName,invocationSource}}function cancelPending(){activeController?.abort("delivery_cancelled")}function close({flush:drain=true}={}){if(!drain){closing=true;stats.dropped+=queue.length;queue.length=0;shutdown2.abort("delivery_cancelled");cancelPending()}if(closePromise)return closePromise;closing=true;clearTimeout(timer);timer=void 0;closePromise=(async()=>{const deadline=setTimeout(()=>shutdown2.abort("shutdown_timeout"),closeTimeoutMs);deadline.unref?.();try{while((queue.length||activeFlush)&&!shutdown2.signal.aborted)await flush();if(activeFlush)await activeFlush;stats.dropped+=queue.length;queue.length=0;return{delivered:stats.delivered,dropped:stats.dropped}}finally{clearTimeout(deadline)}})();return closePromise}const lifecycle={get enabled(){return enabled&&!closing},getStats:()=>Object.freeze({...stats,queued:queue.length,inFlight,closed:closing}),flush,close,cancelPending};if(skillContract){const skills=new Set(skillContract.skills);const workflowId=value=>value===""||typeof value==="string"&&/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);return Object.freeze({...lifecycle,get enabled(){return enabled&&!closing},trackSkillInvoked({skillName,skillTrigger,workflowId:id=""}={}){if(!enabled||closing)return false;if(!skills.has(skillName)||!["user-invoked","agent-invoked","context-load","file-read","unknown"].includes(skillTrigger)||!workflowId(id))return invalid();return enqueue("skill_invoked",{skillName,skillTrigger,workflowId:id,invocationSource:"system"})},trackWorkflowCompleted(actionName,{workflowId:id,skillNames,outcome,durationMs}={}){if(!enabled||closing)return false;const fields=feature(actionName,"system");if(!fields)return false;if(!workflowId(id)||!id||!Array.isArray(skillNames)||skillNames.length>skills.size||skillNames.some(name=>!skills.has(name))||new Set(skillNames).size!==skillNames.length||typeof outcome!=="string"||!Object.hasOwn(FAILURE_CODES,outcome)||!Number.isFinite(durationMs))return invalid();return enqueue("skill_workflow_completed",{...fields,canvasId:skillContract.workflows[actionName].canvasId,workflowId:id,skillNames:Object.freeze([...skillNames].sort()),outcome,durationMs:Math.max(0,Math.min(Math.round(durationMs),36e5)),failureCode:FAILURE_CODES[outcome]})}})}return Object.freeze({...lifecycle,get enabled(){return enabled&&!closing},hasAction:name=>actions.has(name),trackCanvasOpened(invocationSource="unknown"){if(!enabled||closing)return false;if(!SOURCES.has(invocationSource))return invalid();return enqueue("canvas_opened",{invocationSource})},trackUiInteraction(input={}){if(!enabled||closing)return false;if(!input||!INTERACTIONS.has(input.interactionType)||!controlRegistry.has(input.controlId)||controlRegistry.get(input.controlId)!==input.controlType)return invalid();return enqueue("ui_interaction",{invocationSource:"panel",interactionType:input.interactionType,controlId:input.controlId,controlType:input.controlType})},trackFeatureInvoked(actionName,invocationSource="unknown"){if(!enabled||closing)return false;const fields=feature(actionName,invocationSource);return fields?enqueue("feature_invoked",fields):false},trackFeatureCompleted(actionName,{outcome,durationMs,invocationSource="unknown",failureCode}={}){if(!enabled||closing)return false;const fields=feature(actionName,invocationSource);if(!fields)return false;if(typeof outcome!=="string"||!Object.hasOwn(FAILURE_CODES,outcome)||!Number.isFinite(durationMs)||failureCode!==void 0&&failureCode!==FAILURE_CODES[outcome])return invalid();return enqueue("feature_completed",{...fields,outcome,durationMs:Math.max(0,Math.min(Math.round(durationMs),36e5)),failureCode:FAILURE_CODES[outcome]})}})}function createSkillUsageMetrics({contract,pluginVersion,...options}={}){record(contract,"skill contract");identifier(contract.pluginName,"pluginName");if(!Array.isArray(contract.skills)||!contract.skills.length||contract.skills.length>64||new Set(contract.skills).size!==contract.skills.length)throw new TypeError("Register 1-64 unique skills.");for(const name of contract.skills)identifier(name,"skill name");const workflows=record(contract.workflows,"workflows");for(const entry of Object.values(workflows)){record(entry,"workflow");identifier(entry.canvasId,"workflow canvasId")}if(["canvasId","canvasVersion","host","actionUsage","controls"].some(key=>Object.hasOwn(options,key))){throw new TypeError("Skill contract identity cannot be overridden.")}const reviewed=Object.freeze({pluginName:contract.pluginName,skills:Object.freeze([...contract.skills]),workflows:Object.freeze(Object.fromEntries(Object.entries(workflows).map(([name,entry])=>[name,Object.freeze({...entry})])))});return createUsageMetrics({...options,contract:{canvasId:contract.pluginName,host:contract.host,versions:contract.versions,actions:workflows,controls:{}},canvasVersion:pluginVersion},reviewed)}function createPublicSkillUsageMetrics(options={}){for(const key of["enabled","endpoint","audience","getAccessToken","transport","now","randomId"]){if(Object.hasOwn(options,key))throw new TypeError(`Public skill telemetry does not accept ${key}.`)}return createSkillUsageMetrics({...options,enabled:true,endpoint:PUBLIC_CANVAS_USAGE_SERVICE.endpoint,getGitHubUserId:options.getGitHubUserId??resolveGitHubCliUserId})}import{randomUUID as randomUUID2}from"node:crypto";import path from"node:path";function createSkillUsageObserver({contract,metrics:metrics2,skillPaths={},now=Date.now,randomId=randomUUID2,onDiagnostic}={}){const skills=new Set(contract.skills);const files=new Map(Object.entries(skillPaths).filter(([name])=>skills.has(name)).map(([name,file])=>[path.resolve(file),name]));const seen=new Set,pendingReads=new Map,workflows=new Map;let disposed=false;const reported=new Set;function diagnose(code){if(reported.has(code))return;reported.add(code);try{Promise.resolve(onDiagnostic?.({code})).catch(()=>console.warn("Skill telemetry diagnostic callback failed."));if(!onDiagnostic)console.warn(`Skill telemetry: ${code}.`)}catch{console.warn("Skill telemetry diagnostic callback failed.")}}function safe(run){try{return run()}catch{diagnose("skill_observation_failed");return false}}function remember(id){if(typeof id!=="string"||!id||id.length>256||seen.has(id))return false;seen.add(id);if(seen.size>500)seen.delete(seen.values().next().value);return true}function record2(skillName,skillTrigger,event,candidates){if(!skills.has(skillName))return false;const eligible=candidates.filter(item=>workflows.get(item.key)===item&&Date.parse(event.timestamp)>=item.startedAt);const workflow=eligible.length===1&&!event.agentId&&skillTrigger!=="context-load"?eligible[0]:null;const recorded=metrics2.trackSkillInvoked({skillName,skillTrigger,workflowId:workflow?.id??""});if(recorded&&workflow)workflow.skills.add(skillName);return recorded}return Object.freeze({beginWorkflow(key,actionName){if(disposed||!metrics2.enabled)return false;return safe(()=>{if(typeof key!=="string"||!key||key.length>256||!Object.hasOwn(contract.workflows,actionName))return false;if(workflows.has(key))return true;if(workflows.size>=16){diagnose("skill_workflow_limit");return false}workflows.set(key,{key,actionName,id:randomId(),startedAt:now(),skills:new Set});return true})},completeWorkflow(key,outcome){if(disposed||!metrics2.enabled)return false;return safe(()=>{const workflow=workflows.get(key);if(!workflow||!["succeeded","failed","rejected","cancelled"].includes(outcome))return false;workflows.delete(key);return metrics2.trackWorkflowCompleted(workflow.actionName,{workflowId:workflow.id,skillNames:[...workflow.skills],outcome,durationMs:now()-workflow.startedAt})})},observe(event){if(disposed||!metrics2.enabled)return false;return safe(()=>{if(!event||!["skill.invoked","tool.execution_start","tool.execution_complete"].includes(event.type))return false;if(!remember(event.id))return false;const data=event.data;if(!data||typeof data!=="object")return false;if(event.type==="skill.invoked"){const name=data.name;if(data.pluginName?data.pluginName!==contract.pluginName:files.get(typeof data.path==="string"?path.resolve(data.path):"")!==name)return false;const trigger=["user-invoked","agent-invoked","context-load"].includes(data.trigger)?data.trigger:"unknown";return record2(name,trigger,event,[...workflows.values()])}if(event.type==="tool.execution_start"){if(!["view","Read","read_file"].includes(data.toolName)||typeof data.toolCallId!=="string"||data.toolCallId.length>256)return false;const file=data.arguments?.path??data.arguments?.filePath??data.arguments?.file_path;const name=files.get(typeof file==="string"?path.resolve(file):"");if(!name)return false;if(pendingReads.size>=100){diagnose("skill_read_limit");return false}pendingReads.set(data.toolCallId,{name,candidates:[...workflows.values()].filter(item=>Date.parse(event.timestamp)>=item.startedAt),agentId:event.agentId});return true}const read=pendingReads.get(data.toolCallId);pendingReads.delete(data.toolCallId);return read&&data.success===true?record2(read.name,"file-read",{...event,agentId:read.agentId||event.agentId},read.candidates):false})},dispose(){disposed=true;seen.clear();pendingReads.clear();workflows.clear()}})}var CANVAS_AUTHORING_SKILL_USAGE_CONTRACT=Object.freeze({pluginName:"canvas-authoring",host:"copilot_app",skills:Object.freeze(["create-canvas-app"]),workflows:Object.freeze({})});var warn=({code})=>console.warn(`[skill-usage] ${code}`);function skillUsageContract(pluginVersion){return Object.freeze({...CANVAS_AUTHORING_SKILL_USAGE_CONTRACT,versions:Object.freeze([pluginVersion])})}function locateSkills(entryUrl,names=CANVAS_AUTHORING_SKILL_USAGE_CONTRACT.skills){const folder=path2.dirname(fileURLToPath(entryUrl));for(const root of[path2.resolve(folder,"../skills"),path2.resolve(folder,"../../../skills")]){const found=Object.fromEntries(names.map(name=>[name,path2.join(root,name,"SKILL.md")]).filter(([,file])=>existsSync(file)));if(Object.keys(found).length)return found}return{}}function createSkillMetrics({pluginVersion,env=process.env,onDiagnostic=warn}={}){const contract=skillUsageContract(pluginVersion);const disabled=()=>createSkillUsageMetrics({contract,pluginVersion,enabled:false});if(env.CANVAS_AUTHORING_TEST_MODE==="1")return disabled();try{return createPublicSkillUsageMetrics({contract,pluginVersion,releaseChannel:"preview",onDiagnostic})}catch{console.warn("[skill-usage] invalid_configuration");return disabled()}}function observeSkillUsage({session,metrics:metrics2,pluginVersion,skillPaths={},onDiagnostic=warn}){let observer,stop;if(!metrics2.enabled)return Object.freeze({dispose(){}});try{if(typeof session?.on!=="function")throw new TypeError("Session events are unavailable.");observer=createSkillUsageObserver({contract:skillUsageContract(pluginVersion),metrics:metrics2,skillPaths,onDiagnostic});stop=session.on(event=>{observer.observe(event)})}catch{console.warn("[skill-usage] session_observation_unavailable")}return Object.freeze({dispose(){try{stop?.()}catch{console.warn("[skill-usage] unsubscribe_failed")}observer?.dispose()}})}var PLUGIN_VERSION=true?"0.1.2":"0.0.0-source";var metrics=createSkillMetrics({pluginVersion:PLUGIN_VERSION});var usage=Object.freeze({dispose(){}});async function shutdown(){usage.dispose();await metrics.close()}for(const signal of["SIGTERM","SIGINT","disconnect"]){process.once(signal,()=>{void shutdown().finally(()=>process.exit(0))})}try{const session=await joinSession({});usage=observeSkillUsage({session,metrics,pluginVersion:PLUGIN_VERSION,skillPaths:locateSkills(import.meta.url)})}catch{console.warn("[skill-usage] session_unavailable")} +import{joinSession}from"@github/copilot-sdk/extension";import{existsSync}from"node:fs";import path3 from"node:path";import{fileURLToPath}from"node:url";import{randomUUID}from"node:crypto";import{execFile}from"node:child_process";import{closeSync,constants,fstatSync,openSync,readSync}from"node:fs";import{homedir}from"node:os";import path from"node:path";var ID=/^[a-z][a-z0-9_.-]{0,79}$/;var VERSION=/^[a-zA-Z0-9][a-zA-Z0-9.+_-]{0,39}$/;var SOURCES=new Set(["model","panel","system","unknown"]);var CONTROL_TYPES=new Set(["button","link","select","input","textarea","details","other"]);var INTERACTIONS=new Set(["click","change","submit"]);var FAILURE_CODES=Object.freeze({succeeded:"",failed:"action_error",rejected:"action_rejected",cancelled:"action_cancelled"});var RESERVED=new Set(["__proto__","constructor","prototype"]);var USAGE_ENVELOPE_SCHEMA_VERSION=2;var DEFAULT_REQUEST_TIMEOUT_MS=1e4;var PUBLIC_CANVAS_USAGE_SERVICE=Object.freeze({serviceId:"public-canvas-product-usage",schemaVersion:USAGE_ENVELOPE_SCHEMA_VERSION,dataClassification:"pseudonymous_product_usage",endpoint:"https://canvas-metrics-pilot-0b8944-add3hsghhddcbxe9.b01.azurefd.net/api/events"});var PUBLIC_CANVAS_USAGE_ENDPOINT=PUBLIC_CANVAS_USAGE_SERVICE.endpoint;var GITHUB_USER_ID=/^[1-9][0-9]{0,19}$/;function telemetryPreference(){let fd;try{try{const home=process.env.COPILOT_HOME||path.join(homedir(),".copilot");if(!path.isAbsolute(home))return{disabled:true,invalid:true};fd=openSync(path.join(home,"canvases","telemetry.json"),constants.O_RDONLY|constants.O_NONBLOCK);const stat=fstatSync(fd);if(!stat.isFile()||stat.size>4096)return{disabled:true,invalid:true};const bytes=Buffer.alloc(4097);let size=0,count;while(size0)size+=count;if(size>4096)return{disabled:true,invalid:true};const value=JSON.parse(bytes.toString("utf8",0,size));if(!value||Array.isArray(value)||typeof value.enabled!=="boolean")return{disabled:true,invalid:true};return{disabled:!value.enabled,invalid:false}}finally{if(fd!==void 0)closeSync(fd)}}catch(error){return error.code==="ENOENT"?{disabled:false,invalid:false}:{disabled:true,invalid:true}}}function githubCliEnvironment(source){const env={...source};for(const key of Object.keys(env)){if(["GH_TOKEN","GITHUB_TOKEN"].includes(key.toUpperCase()))delete env[key]}return env}function runFile(command,args,options,execFileImpl){return new Promise((resolve,reject)=>{execFileImpl(command,args,options,(error,stdout)=>{if(error)reject(error);else resolve(stdout)})})}async function resolveGitHubCliUserId({signal,env=process.env,execFileImpl=execFile}={}){if(typeof execFileImpl!=="function")throw new TypeError("GitHub CLI executor must be a function.");let stdout;try{stdout=await runFile("gh",["api","--hostname","github.com","user","--jq",".id"],{env:githubCliEnvironment(env),signal,timeout:5e3,maxBuffer:1024,windowsHide:true},execFileImpl)}catch{throw new Error("GitHub identity is unavailable.")}const githubUserId=String(stdout).trim();if(!GITHUB_USER_ID.test(githubUserId))throw new Error("GitHub identity is unavailable.");return githubUserId}function identifier(value,label,pattern=ID){if(typeof value!=="string"||!pattern.test(value)||RESERVED.has(value)){throw new TypeError(`${label} must be a bounded, code-defined identifier.`)}return value}function record(value,label){if(!value||typeof value!=="object"||![Object.prototype,null].includes(Object.getPrototypeOf(value))){throw new TypeError(`${label} must be a plain record.`)}return value}function integer(value,min,max,label){if(!Number.isSafeInteger(value)||valuemax){throw new TypeError(`${label} must be an integer from ${min} to ${max}.`)}return value}function endpoint(value){try{const url=new URL(value);if(url.protocol==="https:"&&!url.username&&!url.password&&!url.search&&!url.hash)return url.href}catch{}throw new TypeError("Telemetry endpoint must be credential-free HTTPS without a query or fragment.")}function createUsageMetrics(options,skillContract){const{contract,canvasVersion,releaseChannel="development",enabled:requestedEnabled=false,endpoint:destination,audience,getAccessToken,transport,fetchImpl=globalThis.fetch,getGitHubUserId,now=Date.now,randomId=randomUUID,onDiagnostic,batchSize=20,maxQueueSize=100,flushIntervalMs=1e3,requestTimeoutMs=DEFAULT_REQUEST_TIMEOUT_MS,closeTimeoutMs=DEFAULT_REQUEST_TIMEOUT_MS}=options;if(contract!==void 0){record(contract,"contract");if(["canvasId","host","actionUsage","controls"].some(key=>Object.hasOwn(options,key))){throw new TypeError("Contract identity and registries cannot be overridden.")}}const canvasId=contract?.canvasId??options.canvasId;const host=contract?.host??options.host??"copilot_app";const actionUsage=contract?.actions??options.actionUsage??{};const controls=contract?.controls??options.controls??{};identifier(canvasId,"canvasId");identifier(canvasVersion,"canvasVersion",VERSION);if(!["copilot_app","mcp_app"].includes(host))throw new TypeError("Unsupported telemetry host.");if(contract!==void 0){if(!Array.isArray(contract.versions)||!contract.versions.length||contract.versions.some(version=>typeof version!=="string"||!VERSION.test(version))){throw new TypeError("Contract versions must be bounded version identifiers.")}if(!contract.versions.includes(canvasVersion)){throw new TypeError("Canvas version is not registered by the telemetry contract.")}}if(!["development","internal","preview","stable"].includes(releaseChannel)){throw new TypeError("Unsupported telemetry release channel.")}if(typeof requestedEnabled!=="boolean")throw new TypeError("enabled must be a boolean.");const preference=requestedEnabled?telemetryPreference():void 0;const enabled=requestedEnabled&&!preference.disabled;const url=destination===void 0&&(!enabled||transport!==void 0)?void 0:endpoint(destination);for(const[name,fn]of Object.entries({now,randomId})){if(typeof fn!=="function")throw new TypeError(`${name} must be a function.`)}if(transport!==void 0&&typeof transport!=="function")throw new TypeError("transport must be a function.");if(getAccessToken!==void 0&&typeof getAccessToken!=="function")throw new TypeError("getAccessToken must be a function.");if(enabled&&!transport&&typeof fetchImpl!=="function")throw new TypeError("fetchImpl must be a function.");if(getGitHubUserId!==void 0&&typeof getGitHubUserId!=="function"){throw new TypeError("getGitHubUserId must be a function.")}if(audience!==void 0&&(typeof audience!=="string"||!audience.trim()||audience.length>512||/[\r\n]/.test(audience)))throw new TypeError("audience must be a bounded nonempty string.");if(onDiagnostic!==void 0&&typeof onDiagnostic!=="function")throw new TypeError("onDiagnostic must be a function.");integer(batchSize,1,100,"batchSize");integer(maxQueueSize,batchSize,1e3,"maxQueueSize");integer(flushIntervalMs,10,6e4,"flushIntervalMs");integer(requestTimeoutMs,10,3e4,"requestTimeoutMs");integer(closeTimeoutMs,10,3e4,"closeTimeoutMs");const actions=new Map(Object.entries(record(actionUsage,"actionUsage")).map(([name,entry])=>{identifier(name,"action name");record(entry,"action usage");identifier(entry.featureId,"featureId");identifier(entry.featureArea,"featureArea");if(!["intentional","automatic","excluded"].includes(entry.usageClass)||typeof entry.mutates!=="boolean"){throw new TypeError("Action usage requires a supported usageClass and boolean mutates.")}return[name,Object.freeze({featureId:entry.featureId,featureArea:entry.featureArea,usageClass:entry.usageClass,mutates:entry.mutates})]}));const controlRegistry=new Map(Object.entries(record(controls,"controls")).map(([id,type])=>{identifier(id,"control ID");if(!CONTROL_TYPES.has(type))throw new TypeError("Unsupported telemetry control type.");return[id,type]}));const queue=[];const stats={delivered:0,dropped:0,invalid:0};const reported=new Set;const shutdown2=new AbortController;let timer,activeFlush,activeController,closePromise;let inFlight=0,closing=false;let resolvedGitHubUserId;function diagnose(code){if(reported.has(code))return;reported.add(code);const warn2=message=>{try{process.emitWarning(message,{code:"CANVAS_USAGE_METRICS"})}catch{}};if(onDiagnostic){const reportFailure=()=>warn2("Canvas telemetry diagnostic callback failed.");try{Promise.resolve(onDiagnostic(Object.freeze({code}))).catch(reportFailure)}catch{reportFailure()}}else{warn2(`Canvas telemetry: ${code}.`)}}function invalid(){stats.invalid+=1;diagnose("invalid_event");return false}if(preference?.invalid)diagnose("preference_unavailable");async function githubIdentity(signal){if(resolvedGitHubUserId)return resolvedGitHubUserId;try{const value=await getGitHubUserId(signal);if(signal.aborted)return;if(typeof value==="string"&&GITHUB_USER_ID.test(value)){resolvedGitHubUserId=value;return value}}catch{}diagnose("identity_unavailable")}function schedule(){if(closing||timer||activeFlush||!queue.length)return;timer=setTimeout(()=>{timer=void 0;void flush()},queue.length>=batchSize?0:flushIntervalMs);timer.unref?.()}function enqueue(eventName,fields={}){if(!enabled||closing)return false;try{const eventId=randomId();if(typeof eventId!=="string"||!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(eventId)){return invalid()}const event=Object.freeze({...skillContract?{schemaVersion:2,pluginName:skillContract.pluginName,pluginVersion:canvasVersion}:{schemaVersion:1,canvasId,canvasVersion},host,releaseChannel,timestamp:new Date(now()).toISOString(),eventId,eventName,invocationSource:"unknown",...fields});const overflow=queue.length===maxQueueSize;if(overflow){queue.shift();stats.dropped+=1}queue.push(event);if(overflow)diagnose("queue_overflow");if(queue.length>=batchSize&&!activeFlush)void flush();else schedule();return true}catch{stats.dropped+=1;diagnose("event_construction_failed");return false}}async function deliver(batch){const controller=new AbortController;activeController=controller;const stop=()=>controller.abort(shutdown2.signal.reason);const deadline=setTimeout(()=>controller.abort("delivery_timeout"),requestTimeoutMs);deadline.unref?.();shutdown2.signal.addEventListener("abort",stop,{once:true});if(shutdown2.signal.aborted)stop();let onAbort;const aborted=new Promise(resolve=>{onAbort=()=>resolve(controller.signal.reason);controller.signal.addEventListener("abort",onAbort,{once:true});if(controller.signal.aborted)onAbort()});const request=Promise.resolve().then(async()=>{if(controller.signal.aborted)return controller.signal.reason;if(transport){await transport(Object.freeze(batch),{signal:controller.signal});return}const headers={"Content-Type":"application/json"};if(getAccessToken){const credential=await getAccessToken(audience,controller.signal);if(controller.signal.aborted)return controller.signal.reason;const token=typeof credential==="string"?credential:credential?.accessToken;if(typeof token!=="string"||!token.trim()||/[\r\n]/.test(token))return"delivery_failed";headers.Authorization=`Bearer ${token}`}const githubUserId=getGitHubUserId?await githubIdentity(controller.signal):void 0;if(getGitHubUserId){if(controller.signal.aborted)return controller.signal.reason}const response=await fetchImpl(url,{method:"POST",headers,body:JSON.stringify({schemaVersion:USAGE_ENVELOPE_SCHEMA_VERSION,...githubUserId?{githubUserId}:{},events:batch}),signal:controller.signal,redirect:"error",credentials:"omit"});const accepted=response?.ok;await response?.body?.cancel();return accepted?void 0:"delivery_rejected"}).catch(()=>"delivery_failed");try{const failure=await Promise.race([request,aborted]);if(failure)diagnose(failure);return{delivered:failure?0:batch.length,dropped:failure?batch.length:0}}finally{clearTimeout(deadline);shutdown2.signal.removeEventListener("abort",stop);controller.signal.removeEventListener("abort",onAbort);if(activeController===controller)activeController=void 0}}function flush(){if(activeFlush)return activeFlush;clearTimeout(timer);timer=void 0;if(!queue.length)return Promise.resolve({delivered:0,dropped:0});const batch=queue.splice(0,batchSize);inFlight=batch.length;activeFlush=deliver(batch).then(result=>{stats.delivered+=result.delivered;stats.dropped+=result.dropped;return result}).finally(()=>{inFlight=0;activeFlush=void 0;schedule()});return activeFlush}function feature(actionName,invocationSource){const usage2=actions.get(actionName);if(!usage2||!SOURCES.has(invocationSource))return invalid();if(usage2.usageClass==="excluded")return false;return{...usage2,actionName,invocationSource}}function cancelPending(){activeController?.abort("delivery_cancelled")}function close({flush:drain=true}={}){if(!enabled){closing=true;return closePromise??=Promise.resolve({delivered:0,dropped:0})}if(!drain){closing=true;stats.dropped+=queue.length;queue.length=0;shutdown2.abort("delivery_cancelled");cancelPending()}if(closePromise)return closePromise;closing=true;clearTimeout(timer);timer=void 0;closePromise=(async()=>{const deadline=setTimeout(()=>shutdown2.abort("shutdown_timeout"),closeTimeoutMs);deadline.unref?.();try{while((queue.length||activeFlush)&&!shutdown2.signal.aborted)await flush();if(activeFlush)await activeFlush;stats.dropped+=queue.length;queue.length=0;return{delivered:stats.delivered,dropped:stats.dropped}}finally{clearTimeout(deadline)}})();return closePromise}const lifecycle={get enabled(){return enabled&&!closing},getStats:()=>Object.freeze({...stats,queued:queue.length,inFlight,closed:closing}),flush,close,cancelPending};if(skillContract){const skills=new Set(skillContract.skills);const workflowId=value=>value===""||typeof value==="string"&&/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);return Object.freeze({...lifecycle,get enabled(){return enabled&&!closing},trackSkillInvoked({skillName,skillTrigger,workflowId:id=""}={}){if(!enabled||closing)return false;if(!skills.has(skillName)||!["user-invoked","agent-invoked","context-load","file-read","unknown"].includes(skillTrigger)||!workflowId(id))return invalid();return enqueue("skill_invoked",{skillName,skillTrigger,workflowId:id,invocationSource:"system"})},trackWorkflowCompleted(actionName,{workflowId:id,skillNames,outcome,durationMs}={}){if(!enabled||closing)return false;const fields=feature(actionName,"system");if(!fields)return false;if(!workflowId(id)||!id||!Array.isArray(skillNames)||skillNames.length>skills.size||skillNames.some(name=>!skills.has(name))||new Set(skillNames).size!==skillNames.length||typeof outcome!=="string"||!Object.hasOwn(FAILURE_CODES,outcome)||!Number.isFinite(durationMs))return invalid();return enqueue("skill_workflow_completed",{...fields,canvasId:skillContract.workflows[actionName].canvasId,workflowId:id,skillNames:Object.freeze([...skillNames].sort()),outcome,durationMs:Math.max(0,Math.min(Math.round(durationMs),36e5)),failureCode:FAILURE_CODES[outcome]})}})}return Object.freeze({...lifecycle,get enabled(){return enabled&&!closing},hasAction:name=>actions.has(name),trackCanvasOpened(invocationSource="unknown"){if(!enabled||closing)return false;if(!SOURCES.has(invocationSource))return invalid();return enqueue("canvas_opened",{invocationSource})},trackUiInteraction(input={}){if(!enabled||closing)return false;if(!input||!INTERACTIONS.has(input.interactionType)||!controlRegistry.has(input.controlId)||controlRegistry.get(input.controlId)!==input.controlType)return invalid();return enqueue("ui_interaction",{invocationSource:"panel",interactionType:input.interactionType,controlId:input.controlId,controlType:input.controlType})},trackFeatureInvoked(actionName,invocationSource="unknown"){if(!enabled||closing)return false;const fields=feature(actionName,invocationSource);return fields?enqueue("feature_invoked",fields):false},trackFeatureCompleted(actionName,{outcome,durationMs,invocationSource="unknown",failureCode}={}){if(!enabled||closing)return false;const fields=feature(actionName,invocationSource);if(!fields)return false;if(typeof outcome!=="string"||!Object.hasOwn(FAILURE_CODES,outcome)||!Number.isFinite(durationMs)||failureCode!==void 0&&failureCode!==FAILURE_CODES[outcome])return invalid();return enqueue("feature_completed",{...fields,outcome,durationMs:Math.max(0,Math.min(Math.round(durationMs),36e5)),failureCode:FAILURE_CODES[outcome]})}})}function createSkillUsageMetrics({contract,pluginVersion,...options}={}){record(contract,"skill contract");identifier(contract.pluginName,"pluginName");if(!Array.isArray(contract.skills)||!contract.skills.length||contract.skills.length>64||new Set(contract.skills).size!==contract.skills.length)throw new TypeError("Register 1-64 unique skills.");for(const name of contract.skills)identifier(name,"skill name");const workflows=record(contract.workflows,"workflows");for(const entry of Object.values(workflows)){record(entry,"workflow");identifier(entry.canvasId,"workflow canvasId")}if(["canvasId","canvasVersion","host","actionUsage","controls"].some(key=>Object.hasOwn(options,key))){throw new TypeError("Skill contract identity cannot be overridden.")}const reviewed=Object.freeze({pluginName:contract.pluginName,skills:Object.freeze([...contract.skills]),workflows:Object.freeze(Object.fromEntries(Object.entries(workflows).map(([name,entry])=>[name,Object.freeze({...entry})])))});return createUsageMetrics({...options,contract:{canvasId:contract.pluginName,host:contract.host,versions:contract.versions,actions:workflows,controls:{}},canvasVersion:pluginVersion},reviewed)}function createPublicSkillUsageMetrics(options={}){for(const key of["enabled","endpoint","audience","getAccessToken","transport","now","randomId"]){if(Object.hasOwn(options,key))throw new TypeError(`Public skill telemetry does not accept ${key}.`)}return createSkillUsageMetrics({...options,enabled:true,endpoint:PUBLIC_CANVAS_USAGE_SERVICE.endpoint,getGitHubUserId:options.getGitHubUserId??resolveGitHubCliUserId})}import{randomUUID as randomUUID2}from"node:crypto";import path2 from"node:path";function createSkillUsageObserver({contract,metrics:metrics2,skillPaths={},now=Date.now,randomId=randomUUID2,onDiagnostic}={}){const skills=new Set(contract.skills);const files=new Map(Object.entries(skillPaths).filter(([name])=>skills.has(name)).map(([name,file])=>[path2.resolve(file),name]));const seen=new Set,pendingReads=new Map,workflows=new Map;let disposed=false;const reported=new Set;function diagnose(code){if(reported.has(code))return;reported.add(code);try{Promise.resolve(onDiagnostic?.({code})).catch(()=>console.warn("Skill telemetry diagnostic callback failed."));if(!onDiagnostic)console.warn(`Skill telemetry: ${code}.`)}catch{console.warn("Skill telemetry diagnostic callback failed.")}}function safe(run){try{return run()}catch{diagnose("skill_observation_failed");return false}}function remember(id){if(typeof id!=="string"||!id||id.length>256||seen.has(id))return false;seen.add(id);if(seen.size>500)seen.delete(seen.values().next().value);return true}function record2(skillName,skillTrigger,event,candidates){if(!skills.has(skillName))return false;const eligible=candidates.filter(item=>workflows.get(item.key)===item&&Date.parse(event.timestamp)>=item.startedAt);const workflow=eligible.length===1&&!event.agentId&&skillTrigger!=="context-load"?eligible[0]:null;const recorded=metrics2.trackSkillInvoked({skillName,skillTrigger,workflowId:workflow?.id??""});if(recorded&&workflow)workflow.skills.add(skillName);return recorded}return Object.freeze({beginWorkflow(key,actionName){if(disposed||!metrics2.enabled)return false;return safe(()=>{if(typeof key!=="string"||!key||key.length>256||!Object.hasOwn(contract.workflows,actionName))return false;if(workflows.has(key))return true;if(workflows.size>=16){diagnose("skill_workflow_limit");return false}workflows.set(key,{key,actionName,id:randomId(),startedAt:now(),skills:new Set});return true})},completeWorkflow(key,outcome){if(disposed||!metrics2.enabled)return false;return safe(()=>{const workflow=workflows.get(key);if(!workflow||!["succeeded","failed","rejected","cancelled"].includes(outcome))return false;workflows.delete(key);return metrics2.trackWorkflowCompleted(workflow.actionName,{workflowId:workflow.id,skillNames:[...workflow.skills],outcome,durationMs:now()-workflow.startedAt})})},observe(event){if(disposed||!metrics2.enabled)return false;return safe(()=>{if(!event||!["skill.invoked","tool.execution_start","tool.execution_complete"].includes(event.type))return false;if(!remember(event.id))return false;const data=event.data;if(!data||typeof data!=="object")return false;if(event.type==="skill.invoked"){const name=data.name;if(data.pluginName?data.pluginName!==contract.pluginName:files.get(typeof data.path==="string"?path2.resolve(data.path):"")!==name)return false;const trigger=["user-invoked","agent-invoked","context-load"].includes(data.trigger)?data.trigger:"unknown";return record2(name,trigger,event,[...workflows.values()])}if(event.type==="tool.execution_start"){if(!["view","Read","read_file"].includes(data.toolName)||typeof data.toolCallId!=="string"||data.toolCallId.length>256)return false;const file=data.arguments?.path??data.arguments?.filePath??data.arguments?.file_path;const name=files.get(typeof file==="string"?path2.resolve(file):"");if(!name)return false;if(pendingReads.size>=100){diagnose("skill_read_limit");return false}pendingReads.set(data.toolCallId,{name,candidates:[...workflows.values()].filter(item=>Date.parse(event.timestamp)>=item.startedAt),agentId:event.agentId});return true}const read=pendingReads.get(data.toolCallId);pendingReads.delete(data.toolCallId);return read&&data.success===true?record2(read.name,"file-read",{...event,agentId:read.agentId||event.agentId},read.candidates):false})},dispose(){disposed=true;seen.clear();pendingReads.clear();workflows.clear()}})}var CANVAS_AUTHORING_SKILL_USAGE_CONTRACT=Object.freeze({pluginName:"canvas-authoring",host:"copilot_app",skills:Object.freeze(["create-canvas-app"]),workflows:Object.freeze({})});var warn=({code})=>console.warn(`[skill-usage] ${code}`);function skillUsageContract(pluginVersion){return Object.freeze({...CANVAS_AUTHORING_SKILL_USAGE_CONTRACT,versions:Object.freeze([pluginVersion])})}function locateSkills(entryUrl,names=CANVAS_AUTHORING_SKILL_USAGE_CONTRACT.skills){const folder=path3.dirname(fileURLToPath(entryUrl));for(const root of[path3.resolve(folder,"../skills"),path3.resolve(folder,"../../../skills")]){const found=Object.fromEntries(names.map(name=>[name,path3.join(root,name,"SKILL.md")]).filter(([,file])=>existsSync(file)));if(Object.keys(found).length)return found}return{}}function createSkillMetrics({pluginVersion,env=process.env,onDiagnostic=warn}={}){const contract=skillUsageContract(pluginVersion);const disabled=()=>createSkillUsageMetrics({contract,pluginVersion,enabled:false});if(env.CANVAS_AUTHORING_TEST_MODE==="1")return disabled();try{return createPublicSkillUsageMetrics({contract,pluginVersion,releaseChannel:"preview",onDiagnostic})}catch{console.warn("[skill-usage] invalid_configuration");return disabled()}}function observeSkillUsage({session,metrics:metrics2,pluginVersion,skillPaths={},onDiagnostic=warn}){let observer,stop;if(!metrics2.enabled)return Object.freeze({dispose(){}});try{if(typeof session?.on!=="function")throw new TypeError("Session events are unavailable.");observer=createSkillUsageObserver({contract:skillUsageContract(pluginVersion),metrics:metrics2,skillPaths,onDiagnostic});stop=session.on(event=>{observer.observe(event)})}catch{console.warn("[skill-usage] session_observation_unavailable")}return Object.freeze({dispose(){try{stop?.()}catch{console.warn("[skill-usage] unsubscribe_failed")}observer?.dispose()}})}var PLUGIN_VERSION=true?"0.1.3":"0.0.0-source";var metrics=createSkillMetrics({pluginVersion:PLUGIN_VERSION});var usage=Object.freeze({dispose(){}});async function shutdown(){usage.dispose();await metrics.close()}for(const signal of["SIGTERM","SIGINT","disconnect"]){process.once(signal,()=>{void shutdown().finally(()=>process.exit(0))})}try{const session=await joinSession({});if(metrics.enabled){usage=observeSkillUsage({session,metrics,pluginVersion:PLUGIN_VERSION,skillPaths:locateSkills(import.meta.url)})}}catch{console.warn("[skill-usage] session_unavailable")} diff --git a/plugins/canvas-authoring/plugin.json b/plugins/canvas-authoring/plugin.json index 6b949ee..2173318 100644 --- a/plugins/canvas-authoring/plugin.json +++ b/plugins/canvas-authoring/plugin.json @@ -1,6 +1,6 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "canvas-authoring", - "version": "0.1.2", - "description": "A toolkit setup companion to the GitHub Copilot app's installed create-canvas skill, with an Azure starter, safe asset packaging and a focused browser smoke." + "version": "0.1.3", + "description": "Create canvas apps with guided setup and reusable UI components from the Microsoft Canvas Toolkit. Includes a background extension used only for skill-usage telemetry." } diff --git a/plugins/canvas-authoring/skills/create-canvas-app/SKILL.md b/plugins/canvas-authoring/skills/create-canvas-app/SKILL.md index 3d2e2ad..d8dc96d 100644 --- a/plugins/canvas-authoring/skills/create-canvas-app/SKILL.md +++ b/plugins/canvas-authoring/skills/create-canvas-app/SKILL.md @@ -82,11 +82,23 @@ install the whole `dist/`, reload the provider and verify the actual panel. - Keep credentials server-side, cancel obsolete work and reject stale results. Empty success, missing scope, errors and cancellation must remain distinct. - Use labelled `{"mode":"fixture"}` data for tests. Do not report it as live Azure. +- Use toolkit controls (`ui/dom`, with `ui/styles.css` then `ui/base.css`) and host + type tokens for new UI. Keep body text normal weight and headings modest; avoid + hand-styled buttons. The Azure starter adds no product telemetry. - Keep the generated build's dependencies and asset layout. Check decoded images as well as controls; do not replace missing assets with SVG rewrites. - State is ephemeral. Follow the host skill for persistence and shutdown. A panel refresh is not a provider reload; check the displayed build ID. +### When adding product telemetry + +Do not add telemetry by default to generated apps. If requested, use the +shared toolkit collector and follow +[Product-usage telemetry](references/toolkit/README.md#product-usage-telemetry). +Update **Usage data** in the public and packaged README, link to the shared +disable instructions, and review the emitted notice. Test opt-out behavior, +not README phrases. + Use the [integration reference](references/toolkit.md) for adapter/build details and the [Azure quickstart](references/toolkit/quickstart.md) for customization. Toolkit guide links are assembled in the exported plugin, not maintained as diff --git a/plugins/canvas-authoring/skills/create-canvas-app/references/toolkit.md b/plugins/canvas-authoring/skills/create-canvas-app/references/toolkit.md index 7d4437d..9c02f00 100644 --- a/plugins/canvas-authoring/skills/create-canvas-app/references/toolkit.md +++ b/plugins/canvas-authoring/skills/create-canvas-app/references/toolkit.md @@ -44,6 +44,9 @@ An unavailable version must fail installation, not trigger a source fallback. | `@microsoft/canvas-toolkit/state` | The optional store is in-memory, not durable persistence. Choose ownership through the host lifecycle. | | `@microsoft/canvas-toolkit/server` | [Serve the app](toolkit/README.md#canvas-skeleton) through the shared loopback transport, with an explicit asset allowlist. | | `@microsoft/canvas-toolkit/ui/styles.css` | [Use shared styles](toolkit/README.md#styling-and-themes) and link the emitted CSS from HTML. | +| `@microsoft/canvas-toolkit/ui/dom` | Mount toolkit controls (`Button`, `MessageBar`, `ActionGroup`) in plain-JavaScript views with `mountCanvasComponent`; load `ui/styles.css`, then `ui/base.css`. Use `ui/react` in React apps. See the [component guide](toolkit/ui-components.md). | +| `@microsoft/canvas-toolkit/ui/theme` | `readCanvasTheme` and `observeCanvasTheme` for renderers that cannot use CSS tokens. Otherwise rely on `--canvas-*` tokens. | +| `@microsoft/canvas-toolkit/telemetry` | Product-usage helpers. The starters omit them: the public collector is release-owned and always on. Add usage metadata and instrumentation only for a registered product canvas. | | Azure auth/subscriptions | [Bind explicit scope](toolkit/auth.md) and keep credentials in Node. These helpers are optional for non-Azure apps. | `effect: "write"` describes an action; it does not authorize a cloud mutation. diff --git a/plugins/canvas-authoring/skills/create-canvas-app/references/toolkit/README.md b/plugins/canvas-authoring/skills/create-canvas-app/references/toolkit/README.md index 00d6ae7..6a5905b 100644 --- a/plugins/canvas-authoring/skills/create-canvas-app/references/toolkit/README.md +++ b/plugins/canvas-authoring/skills/create-canvas-app/references/toolkit/README.md @@ -18,7 +18,12 @@ Already have an app? Choose an [entry point](#entry-points) below or browse the This reference covers [app structure](#canvas-skeleton), [Azure access](#azure-access), [UI and packaging](#shared-ui), and optional -[telemetry](#product-usage-telemetry) and [command logs](#commands-log). +[telemetry](#product-usage-telemetry), [shared UI components](#shared-interactive-components), +and [command logs](#commands-log). + +For the shared UI migration set, see the compact +[component guide](ui-components.md): when to use each component, when not to, +and how to compose it without taking over product behavior. ## Setup and exports @@ -69,6 +74,11 @@ They do not require access to a source repository. | `@microsoft/canvas-toolkit/commands` | `createCommandLog`; server-side, DOM-free, redaction-safe command-activity record log | | `@microsoft/canvas-toolkit/ui/commands-log` | `createCommandsLog` (browser module); the read-only command-activity panel | | `@microsoft/canvas-toolkit/ui/commands-log.css` | Command-activity panel styles | +| `@microsoft/canvas-toolkit/ui/components` | Vanilla DOM header/footer/navigation, cards, composer, list/detail, shell, feedback, filters, and overlays | +| `@microsoft/canvas-toolkit/ui/components.css` | Scoped styles for the shared interactive components | +| `@microsoft/canvas-toolkit/ui/react` | Host-themed Fluent Headless components, canvas chrome and layouts; use for new UI | +| `@microsoft/canvas-toolkit/ui/dom` | `mountCanvasComponent`, React element helpers and the same Fluent Headless components for vanilla consumers | +| `@microsoft/canvas-toolkit/ui/base.css` | Alex's Workbench styling for the Fluent Headless components; load after `ui/styles.css` | | `@microsoft/canvas-toolkit/telemetry` | `createPublicCanvasUsageMetrics` for the release-owned collector; default-off `createCanvasUsageMetrics` for caller-owned collection; action instrumentation helpers | | `@microsoft/canvas-toolkit/ui/telemetry` | `observeCanvasUsage`; trusted panel interactions from a static control allowlist | @@ -125,7 +135,7 @@ public collector or the lower-level client for your own collection policy. The toolkit exposes two layers: -- `createPublicCanvasUsageMetrics` is always enabled, credential-free, and uses +- `createPublicCanvasUsageMetrics` is enabled by default, credential-free, and uses the release-owned public endpoint. It rejects consumer enablement, destination, transport, clock, event-ID generator, audience, and token-provider options. @@ -137,8 +147,10 @@ registered public canvas. It owns one service ID, schema version, data classification, and event endpoint; canvases do not define per-canvas destinations. `PUBLIC_CANVAS_USAGE_ENDPOINT` remains an alias for compatibility. -Neither API reads environment variables. Never reuse a customer's Application -Insights connection for product-usage events. +Customers can [disable usage telemetry](https://github.com/microsoft/azure-dev-tools#disable-usage-telemetry) +for canvas and skill collectors. The preference is read at startup; restart +the provider after changing it. Never reuse a customer's +Application Insights connection for product-usage events. ```js import { createPublicCanvasUsageMetrics } from "@microsoft/canvas-toolkit/telemetry"; @@ -150,8 +162,9 @@ const metrics = createPublicCanvasUsageMetrics({ }); ``` -Public canvases expose no customer setting that disables or redirects this -collector. The shared client asks the stored `github.com` GitHub CLI account for +Customers can disable collection but cannot redirect the public collector or +supply its credentials. When collection is enabled, the shared client asks the +stored `github.com` GitHub CLI account for its numeric user ID without exporting a GitHub token. That ID crosses the TLS-protected request path and is HMACed by the gateway; only the stable server-derived key and key version are persisted for the 30-day repeat-customer @@ -165,6 +178,10 @@ source network information. Credential-free traffic and claimed GitHub IDs are spoofable, so these counts must not drive billing, entitlement, compliance, or security decisions. +When adding collection, update **Usage data** in the public and packaged +README with the actual default, data, receiver and public disable link. +Review the emitted notice and test opt-out behavior, not README phrases. + Use the lower-level API when collection policy and transport are explicitly owned by the caller: @@ -483,6 +500,282 @@ primitives. `hydrateIcons()` replaces marked elements such as `` with decorative SVGs. Consumers still implement accessible labels, keyboard interaction, and state. +### Shared interactive components + +For new UI, use **`ui/react`**: Fluent Headless controls with host-themed +Workbench styling and bundled Microsoft focusgroup support for keyboard navigation. +React applications wrap a subtree in `CanvasProvider`; plain JavaScript uses +`mountCanvasComponent` from **`ui/dom`**. The [component guide](ui-components.md) +has both examples and short use/avoid guidance for all components. + +Load `ui/styles.css` and `ui/base.css`. App bundlers use the application's +React/React DOM peer dependencies. For native browser ESM, `canvasUiAssets` +includes `canvas-ui/base.mjs` and its shared `react-runtime.mjs` chunk; both +prepared browser entry points map to the same implementation. Keep the complete +asset map and its third-party notices when packaging. Do not mix bundled React with an application's +separate React runtime. No CDN, import map or unsafe evaluation is required. +Non-UI entry points do not import React. + +**Legacy factory compatibility.** The examples below describe `ui/components`, +which remains unchanged for existing consumers. It does not become React-backed +just by loading the new stylesheet. Migrate a self-contained subtree to the new +entry points; do not place a React root inside another React-owned subtree. + +The optional `ui/components` entry point adds lifecycle-managed browser DOM +primitives without importing React, React DOM, Fluent Headless, or product services. +Load both shared stylesheets and mount these components in a host-owned element: + +```js +import { + createActionCard, createCanvasShell, createListDetail, createPromptComposer, +} from "@microsoft/canvas-toolkit/ui/components"; +import "@microsoft/canvas-toolkit/ui/styles.css"; +import "@microsoft/canvas-toolkit/ui/components.css"; + +const shell = createCanvasShell({ mount, title: "Resources" }); +const card = createActionCard({ + mount: shell.content, + label: "Refresh resources", + description: "Load the latest resource list", + onAction: () => refresh(), +}); +const list = createListDetail({ + mount: shell.content, + items: [{ id: "one", label: "Resource one", description: "Ready" }], + onSelect: item => selectResource(item.id), + renderDetail: item => { + const detail = document.createElement("p"); + detail.textContent = item.description ?? ""; + return detail; + }, +}); +const composer = createPromptComposer({ + mount: shell.content, + onSubmit: async message => sendMessage(message), +}); + +// On rerender, update props without replacing the surrounding application. +list.update({ items: nextItems, selectedId: currentSelection }); +// At the owning view's teardown: +for (const component of [composer, list, card, shell]) component.dispose(); +``` + +`createPromptComposer` submits with its button or Ctrl/⌘+Enter by default. Set +`submitKey: "enter"` for Enter-to-send with Shift+Enter for a newline. +It ignores IME +composition, prevents duplicate sends while pending, and clears only after an +accepted send. Return `false` or throw to keep the draft; thrown errors appear +beside the composer. The callback is only a UI intent: the canvas owns transport, +authorization, retries, and persistent state. `createListDetail` owns only the +displayed items and current selection; queries, paging, and domain semantics +remain with the app. Text is rendered as text, not parsed HTML. + +Legacy React integrations can retain this ref/effect boundary. New React views +should use the React components directly rather than mounting legacy factories: + +```jsx +import { useEffect, useRef } from "react"; +import { createPromptComposer } from "@microsoft/canvas-toolkit/ui/components"; + +export function PromptComposer({ onSubmit }) { + const host = useRef(null); + const submit = useRef(onSubmit); + submit.current = onSubmit; + useEffect(() => { + const composer = createPromptComposer({ + mount: host.current, + onSubmit: value => submit.current(value), + }); + return () => composer.dispose(); + }, []); + return
; +} +``` + +For a gradual migration, start with one self-contained card or composer inside +an existing React view. Keep app state and authorized operations in their +current owner, then adopt list/detail or the shell only when it reduces real +duplication. Existing Workbench React components and product canvases remain +supported; this entry point does not imply a broad migration or replace their +current behavior. + +#### Cohesive canvas header, footer, and navigation + +Use these three components independently or compose them through the shell. +They share the same typography, 32px controls, theme tokens, wrapping, and +update/dispose lifecycle. Optional features leave no empty control groups. + +```js +import { + createCanvasHeader, createCanvasFooter, createNavigationControls, +} from "@microsoft/canvas-toolkit/ui/components"; + +const navigation = createNavigationControls({ + canGoBack: history.canGoBack, + onBack: () => history.back(), + onForward: () => history.forward(), + onJump: () => openDestinationPicker(), +}); +const header = createCanvasHeader({ + mount, + title: "Operations", + navigation: navigation.element, + context: { label: selectedName, onSelect: () => openContextPicker() }, + actions: [{ label: "Refresh", onClick: () => refresh() }], +}); +const footer = createCanvasFooter({ + mount, + docs: { label: "Docs", href: "https://example.com/docs", newTab: true }, + feedback: { label: "Feedback", onClick: () => openFeedback() }, + version: "1.0.0", +}); + +navigation.update({ canGoBack: true, canGoForward: false }); +header.update({ context: null }); // Remove context and its separator together. +// At the owning view's teardown: +navigation.dispose(); +header.dispose(); +footer.dispose(); +``` + +| Factory | Initial contract | +|---|---| +| `createCanvasHeader` | Required `title`; optional decorative `icon` element, `description`, `onHome`/`homeLabel`, selected `context`, `navigation` element, and `actions` | +| `createCanvasFooter` | Optional `docs`, `feedback`, `language`, `actions`, `actionGroups`, and `version`; an entirely empty footer is hidden | +| `createNavigationControls` | Optional `onBack`, `onForward`, `onJump`; caller-supplied `canGoBack`/`canGoForward`; independently localized labels | + +Header context is `{ label, disabled?, onSelect? }`; without a callback it is +displayed as unavailable, not as a working picker. The icon slot is decorative; +do not put interactive controls inside it. Navigation is caller-owned: disposing +the header does not dispose the component mounted into its navigation slot. +`shortcutLabel` on navigation is a visual hint, not a registered keyboard +shortcut. Set `jumpControls` only when the caller provides the associated +destination dialog. No router, history store, or command palette is installed. + +The footer's `language` option is +`{ label, value, options: [{ value, label }], onChange, disabled? }`. +It uses a native select rather than a custom translation menu. The async +`onChange(value)` callback owns translation: selection is locked while pending; +failure restores the prior locale and displays the error. Replacing the +language configuration or disposing the footer invalidates late completions. +Omit `language` when translation is not supported. + +Footer action groups use `{ label, items: [{ label, disabled?, onSelect? }] }` +and reuse the shared menu component. Docs links support HTTP(S) or relative URLs; +`newTab` adds the appropriate opener protection. Version is displayed exactly +as supplied; `versionLabel`, `label`, and `actionsLabel` localize its accessible +copy and footer/action landmarks. + +`createCanvasShell` uses these same header/footer factories. Its existing +`title`, `description`, `headerActions`, and `footerActions` options remain +supported. Pass additional chrome options through `header` and `footer`: + +```js +const shell = createCanvasShell({ + mount, + title: "Operations", + header: { navigation: navigation.element }, + footer: { docs: { label: "Docs", href: "/docs" }, version: "1.0.0" }, +}); +shell.update({ header: { context: { label: selectedName } } }); +``` + +The returned `shell.header` and `shell.footer` also expose their individual +update APIs. Shell disposal cleans up its header and footer, but callers still +dispose components they supply in slots. Product routes, selected resources, +connection state, feedback destinations, and language services remain outside +the toolkit. + +For incremental adoption, replace the footer first, then supply the existing +navigation callbacks to the header. React consumers use the same ref/effect +boundary shown above: React owns the host, the toolkit owns its children. +Keep the navigation instance alongside the header and dispose both on cleanup; +pass new state through `update` rather than rebuilding the host. + +#### Feedback and empty states + +All factories expose `element`, `update(patch)`, and `dispose()` (also `destroy()`). +Text options are plain text, never HTML. Labels and callbacks stay caller-owned. + +| Factory | Options and behavior | +|---|---| +| `createSpinner` | `label`, `paused`; labeled status with reduced-motion support | +| `createProgressBar` | `label`, optional `value` from 0–100; omit value for indeterminate progress | +| `createSkeleton` | `label`, `lines` from 1–20; decorative placeholder lines with one accessible status | +| `createMessageBar` | Required `title`, optional `message`, `intent`: `info`, `success`, `warning`, or `error`; errors use alert semantics | +| `createBlankslate` | Required `title`, optional `description` and `actions`; actions use the shell's label/primary/disabled/onClick shape | + +Progress limits and skeleton line counts are checked, not silently clamped. +Update feedback in place rather than recreating it on every state push. + +#### Search and filters + +```js +import { createFilterToolbar } from "@microsoft/canvas-toolkit/ui/components"; + +const filters = createFilterToolbar({ + mount, + searchLabel: "Search resources", + facets: [{ + id: "status", + label: "Status", + options: [ + { value: "active", label: "Active" }, + { value: "closed", label: "Closed" }, + ], + values: [], + }], + onChange: ({ search, values }) => applyQuery({ search, values }), +}); +// After the caller's query completes: +filters.update({ resultCount: 12 }); +``` + +Changes emit a full `{ search, values }` snapshot. Values are arrays keyed by +facet ID; empty arrays mean unrestricted. The caller implements query semantics +(typically OR within a facet and AND across facets), debouncing, paging, and +request cancellation. The control does not fetch or filter a resource inventory. +`getValue()` returns a copy; programmatic `update()` does not emit changes. +When replacing `facets`, include their selected `values` explicitly. + +Selected values appear as removable pills; `createFilterPill` is also available +separately with `label`, `removeLabel`, and `onRemove`. Search remains visible +while measured facet triggers overflow, in priority order, into More filters. +Overflow preserves selection and uses the same facet controls, not duplicate +filter state. `searchLabel`, `moreLabel`, `clearLabel`, and `removeLabel` +customize control copy; `formatResultCount(count)` localizes the count announcement. +`resultCount` is caller-supplied; omit it when unknown rather than implying zero. + +#### Dialogs, popovers, menus, and tooltips + +| Factory | Contract | +|---|---| +| `createDialog` | Required `title`; `content` and `footer` slots, `open()`, `close()`, optional `onClose` and `closeLabel` | +| `createPopover` | Caller-owned `trigger` and required `label`; `content` slot, `open()`, `close(returnFocus?)`, `isOpen()` | +| `createMenuButton` | Required `label`, `items: [{ label, disabled?, onSelect? }]`; owns its `trigger`, supports arrow keys, Home, End, type-to-focus, and Escape | +| `createTooltip` | Caller-owned focusable `trigger` and required `text`; supplementary description on hover/focus, dismissed with Escape | + +Dialogs use native `showModal()` for modal focus and Escape behavior. Popovers +and menus use the native Popover API for top-layer rendering and outside-click +dismissal; they are not modal. Unsupported browsers fail explicitly at creation. +Mount overlays into the connected document before opening them. Popovers clamp +to the iframe viewport and reposition on scroll/resize. Tooltips contain text +only; use a popover for interactive content. + +Slots accept DOM nodes authored by the caller. Keep authorization and service +operations in the application; opening a dialog or selecting a menu item grants +no permission. Dispose caller-owned children before disposing their container. +Disposal removes overlay listeners and restores trigger accessibility attributes, +so React effects can safely unmount and remount. A programmatic popover `open()` +does not move focus; activating its trigger focuses the first control or panel. + +For adoption, replace loading/empty/error blocks first, then connect the filter +snapshot to the existing query controller. Adopt overlays independently of the +shell. Keep investigation selection, draft persistence, confirmation workflows, +and creation/deployment operations in their current owners. The composer’s +`submitKey: "enter"` supports existing Enter-to-send experiences without changing +the default for other consumers. + ### Packaging shared UI Use the Node-only build helper before bundling your provider and browser entry: diff --git a/plugins/canvas-authoring/skills/create-canvas-app/references/toolkit/provenance.json b/plugins/canvas-authoring/skills/create-canvas-app/references/toolkit/provenance.json index 0c0b2f8..60d590e 100644 --- a/plugins/canvas-authoring/skills/create-canvas-app/references/toolkit/provenance.json +++ b/plugins/canvas-authoring/skills/create-canvas-app/references/toolkit/provenance.json @@ -3,13 +3,14 @@ "version": "0.1.0", "files": { "LICENSE": "d25deed12d803f6c973597348627b1939e2d19bbc28fda83c93ea0cde44702da", - "README.md": "40d66d89c564be6bcb3e6741deed224417d3bc051cd3585ec665aa7a2eeae6a8", + "README.md": "63b940887b422af5da156b3ece522ec90afd26bd9e5b8271aad2fe60735ed814", "auth.md": "b22b317cd93b5fb40636fbaccba2750501faf41b54bcf9c6bb971c5355c9e23b", "examples/README.md": "70d8d6683e31c01ce8538d339f676901ac1332710dc19ff8afbfa443b0ae399b", "examples/custom-fetch-client.mjs": "18ea90a17fb7d39104f343b3a53079dfed536bdd9a6dafc71321de5158be61bb", "examples/resource-groups.mjs": "3d81ea6de1551da83f008251f204e06e736e9655aed450765b4f416a834d1eb8", "examples/run-read-only.mjs": "5809596d28119f393975235a44b5784d95a82354eb56d84c2adcbba0f2ab34ce", "examples/sdk-client.mjs": "e9adf9797984f6b23f2f7b0807898a551f2ca5ab122f5f644033e705e2efa1cc", - "quickstart.md": "90d339293305a4131381db2170bda0b68fd3dba63a78259f21d373b096bfae5e" + "quickstart.md": "90d339293305a4131381db2170bda0b68fd3dba63a78259f21d373b096bfae5e", + "ui-components.md": "c1c94cf568e5fdef3013b81aeb63e9b7aa1d2efa983f19677f3642f7ebacf0a9" } } diff --git a/plugins/canvas-authoring/skills/create-canvas-app/references/toolkit/ui-components.md b/plugins/canvas-authoring/skills/create-canvas-app/references/toolkit/ui-components.md new file mode 100644 index 0000000..8cf08de --- /dev/null +++ b/plugins/canvas-authoring/skills/create-canvas-app/references/toolkit/ui-components.md @@ -0,0 +1,434 @@ +# Shared canvas UI + +Use `@microsoft/canvas-toolkit/ui/react` for new UI. Load `ui/styles.css` then +`ui/base.css`, and wrap the view in `CanvasProvider`. These are Fluent Headless +React controls with the Workbench visual treatment, not a second focus engine. +The provider follows late host tokens and tone changes without remounting. + +```jsx +import { CanvasProvider, FormField, TextInput, Button } from "@microsoft/canvas-toolkit/ui/react"; +import "@microsoft/canvas-toolkit/ui/styles.css"; +import "@microsoft/canvas-toolkit/ui/base.css"; + +function App() { + return + + + + + ; +} +``` + +Plain JavaScript uses the **same implementation**, not parallel widget logic: + +```js +import { mountCanvasComponent, ActionCard } from "@microsoft/canvas-toolkit/ui/dom"; +const card = mountCanvasComponent(container, ActionCard, { + title: "Refresh resources", description: "Reload the current scope.", + onClick: () => refresh(), +}); +card.update({ disabled: true }); +card.dispose(); +``` + +For existing vanilla canvases, `ui/dom` also provides `actionCard` / +`updateActionCard`, `folderField`, `appFooter`, `promptComposer`, `renderView` +and `hostMessageToast`. These adapters mount the canonical components through +the same retained-root implementation, not a second renderer. Card adapters +return a mount wrapper containing the actual button; locate the button for +focus or native button properties rather than treating the wrapper as a button. +Use `dispose(element)` or `replaceChildren(element, ...)` before removing a +subtree containing toolkit roots. A disposed mount is idempotent and reports +`disposed`; reuse a cleared container with a new mount. `renderView` updates +an existing root and replaces its action callback without remounting. + +`bindPromptComposer`, `bindActionGroup`, `bindInlineEditor`, +`bindDetailPanel`, `bindProductNavigation` and `createNavigation` provide +vanilla lifecycle/controller integration. Routing, authorization, asynchronous +actions and display snapshots remain caller-owned. History stores at most 80 +display snapshots, serializes restoration and rolls back a failed restore; +it never replays a product operation. Dispose controller listeners on permanent +navigation, not on a persisted `pagehide` when the browser is retaining the page. + +`PromptComposer` supports controlled `value` or uncontrolled `defaultValue`. +The `input` and `submitButton` slots retain caller IDs, accessible names and +native hooks. Uncontrolled input drafts survive prop refreshes, including drafts +owned by an existing vanilla controller. The `data-composer-actions` row can +be hidden for a caller-owned setup/review step without hiding the draft. +`SymbolIcon` supplies a small named Primer symbol set for common canvas intents; +it does not load an icon catalog or alter routing. + +`MenuButton` exposes native `button`, `popover` and `menu` slots. Use +`MenuItem.persistOnClick` when the caller must keep a menu open until an +asynchronous action succeeds; keep pending and failure state caller-owned. +The footer adapter uses this behavior for language saves, retains failures +in the open menu, and marks the active language with `aria-current`. +Its language trigger defaults to `${ids.locale}-trigger`, or use +`ids.localeTrigger` for an explicit ID. +The logical `ids.locale` remains the trigger's `data-metric-id`; the consuming +canvas still decides which controls are registered and whether to observe usage. +For joined cards, render their nodes together through `renderView` or one +React root so first/last styling sees the actual sibling buttons. + +`CanvasHeader.contextVisibility="always"` prioritizes a visible selected context. +Below 900 pixels of available header width, its Jump to control becomes +icon-only while retaining its accessible name and keyboard shortcut. Hidden +contexts and the default responsive layout keep the existing behavior. + +`CommandPalette` supports localized result labels, grouped commands, controlled +queries and caller-controlled closing. Native command-button handlers run +before selection and can prevent it. Native-disabled and `aria-disabled` +commands never invoke `onCommand`; focusable unavailable rows remain reachable +with Home/End and the shared keyboard navigation. A caller can use native +capture handlers to explain why an unavailable action cannot run. + +Controls and overlays follow a profile's `--canvas-font-family` mapping or the +delivered `--font-sans` token with the standard system fallback. The CoreAI +profile maps its existing type stack to the same alias, including for overlays. +Uncontrolled `TextInput`, `Textarea` and `PromptComposer` values also +retain native, programmatically restored drafts across unrelated updates. + +Serve the complete `canvasUiAssets` map or use `prepareCanvasUiAssets`. +For native browser modules both `ui/react` and `ui/dom` resolve to the single +prepared `canvas-ui/base.mjs` entry and shared React chunk, which also export `createElement`, +`Fragment`, `useState`, `useEffect` and `useRef`. Do not mix that bundled React +with another React runtime. App bundlers use the raw entry points and the app's +React/React DOM peers (18.3.1 or 19). Public component props are toolkit-owned; +they do not expose a styling vendor's types. +TypeScript apps also install `@types/react` and `@types/react-dom`. Keep the +complete same-origin asset map, including the bundled third-party notices. + +**Compatibility:** `ui/components` still exports the existing `create*` DOM +factories and uses `components.css`. Its props and async lifecycle are separate; +it is not silently converted to React. Keep it while migrating one subtree at +a time. New React slots accept React nodes, never DOM nodes or HTML strings. +Existing toolkit callbacks retain `onChange(event, {value})`, +`onChange(event, {checked})` and `onOpenChange(event, {open})`; progress uses 0–1 +values by default. `ui/fluent.css` and the old `canvas-ui/fluent.mjs` browser +route remain compatibility aliases to the same canonical implementation. + +## Interaction ownership + +`@fluentui/react-headless-components-preview` owns menu/toolbar/tab +navigation, listbox selection, dialogs, popovers, tooltips and choice semantics. +`CanvasProvider` initializes the bundled Microsoft focusgroup polyfill; consumers +do not install another arrow-key engine. Menus use native popovers and can remain +mounted while closed. Dialogs use native modality: the in-page background is +inert, focus returns to the trigger, and Tab may visit browser chrome before +cycling back. The adapter preserves initial input focus and focusable disabled +tabs without allowing disabled selection. +Do not add roving tabindex or another focus trap around them. The canvas still +owns labels, error announcements, async pending/error state, drafts, stale-result +invalidation and authorization. These event callbacks do not await operations or +turn rejected promises into UI; handle failures and pass `error`/`pending`. + +### Product header composition + +Compose `CanvasHeader` with `NavigationControls` and `ProductNavigation`; the +canvas retains routing, history snapshots, permissions and current-task state. +`homeButton` supplies the home control's accessible name, ref and native props. +`contextHidden` keeps a selected-resource control mounted while hiding it; +`contextVisibility="always"` retains that required context in narrow panes. +The default `"responsive"` policy hides context below 480px. + +`NavigationControls.label` localizes its navigation landmark. `backButton`, +`forwardButton` and `jumpButton` provide IDs, refs, accessible relationships and +other native button props. Navigation callbacks remain the explicit `onBack`, +`onForward` and `onJump` handlers; a disabled history control cannot be enabled +by its slot props. Product destinations may supply `button` native props for +telemetry and dialog relationships; `onNavigate` and `current` still own +selection and routing. A displayed shortcut is a hint, not a registered key. +Do not use quick navigation to bypass a task's review or approval. + +**Browser support:** overlays require native ``, the Popover API and CSS +anchor positioning in the target browser or embedded webview. The bundled +focusgroup polyfill supplies keyboard navigation, not those platform features. +Check the actual packaged host before adopting the preview runtime; do not +silently disable CSP or assume an older webview has a positioning fallback. + +## Making a small component change + +Start with the public component name and one observable problem, not a coverage +target. `src/ui/react.mjs` is canonical; `src/ui/react.d.ts` is its public prop +contract. `src/ui/dom.mjs` mounts that same implementation. The `create*` +compatibility factories have separate contracts; do not use their passing tests +as proof of React behavior. + +From the repository root, select the same scenario used for human review: + +```sh +node packages/canvas-toolkit/scripts/test-ui.mjs --component=Disclosure --list +node packages/canvas-toolkit/scripts/test-ui.mjs --component=Disclosure --scenario=retained-disclosure +``` + +The selector consumes `dev/scenarios.mjs`, deduplicates exact existing test +names, and runs the selected files serially through Node's test runner. Missing +checks, stale mappings, skipped tests and zero-test runs fail rather than +claiming verification. `--list` shows the actual checks before execution. +A component selection includes shared compositions that name it, not just its +isolated sample. A smoke check establishes mounting/asset closure, **not** every +interaction of the selected component. Inspect the mapped names and add a +missing behavior when the change requires it. + +### Choose the smallest useful regression + +| Change | Useful evidence | +| --- | --- | +| Toolkit-owned deterministic validation, selection or lifecycle logic | A small unit test with an observable result and one meaningful failure/boundary. No browser needed. | +| Interaction, focus, keyboard, hidden drafts or async recovery | One user-oriented check in `tests/browser/ui-fluent-browser.test.mjs`, using the real canonical runtime under CSP. Exercise the gesture and assert the resulting state/callback/focus, not implementation text. | +| DOM mounting or compatibility behavior | The appropriate DOM/migration test, including draft identity and disposal when relevant. Do not duplicate the canonical interaction suite. | +| Public props or exports | Update the consumer in `tests/types`; run the existing strict public type lane. | +| Runtime assets, dependencies or packaging | Rebuild prepared browser assets; run asset closure and installed-package checks. Source success is not installed success. | +| Visual-only spacing, typography or copy | Inspect actual computed layout and the real canvas at relevant widths/themes. Add a browser assertion only for a stable requirement likely to regress, not every CSS property. | + +Before adding a test, read the nearby existing checks. Extend the closest +regression when it already expresses the same user risk. Do **not** add tests +for Fluent internals, incidental markup, every prop combination, duplicate +snapshots, CSS source strings as visual proof, or a new assertion solely to +increase counts. A static component can reasonably have no dedicated unit +behavior test; that is not a claim that all its behavior is verified. +Keep intentional global integrity checks and mandatory browser discovery. +Use synthetic data; no Azure login or resource mutation is needed here. + +### Agent handoff: make it reviewable in a canvas + +Reproduce the problem with the existing scenario before editing when possible. +Keep assertions outside the example: reusing a fixture is useful; asserting +that the fixture implements itself is not. After the fix, run the focused +command and prepare the frozen review artifact with `npm run dev:canvas-toolkit`. +Open the **registered toolkit workbench canvas** on the same component/scenario +for the human to try: + +```js +list_canvas_capabilities({ canvasId: "toolkit-component-workbench" }); +open_canvas({ + canvasId: "toolkit-component-workbench", + instanceId: "disclosure-review", + input: { component: "Disclosure", scenario: "retained-disclosure" }, +}); +``` + +Use the current capabilities for action schemas; `get_state` returns the +selection and frozen artifact digest. A Browser canvas around a webpage is not +a substitute. Do not reload a global provider or an existing editing panel. + +An honest after-only handoff is enough when no unchanged before artifact was +captured: "After only, Disclosure / retained-disclosure: expand, type a draft, +collapse, update and reopen; the same draft should remain." Identify the exact +build and state which checks actually ran. An open source preview does not prove +that a packaged build or its host integration works. + +For before/after, freeze **distinct** build directories before and after the +change, including the prepared runtime and fixture dependency closure. Record +each source revision plus dirty patch or content digest, build command, artifact +digest and scenario. Keep both artifacts immutable and pass their absolute +directories as the workbench's `before` and `after` inputs; its `workbench.json` +checksums identify each build and equal digests are rejected. Do not relabel two renders of current source as +before/after, overwrite the before build, or fabricate a baseline screenshot. +If the old build cannot be reproduced, say after-only and keep the limitation. + +Focused checks are an inner loop, not a replacement for existing gates: + +```sh +node scripts/testing/run.mjs --workspace=@microsoft/canvas-toolkit --tier=unit +node scripts/auth/run-tests.mjs types +node scripts/auth/run-tests.mjs --files packages/canvas-toolkit/tests/browser/ui-fluent-browser.test.mjs +``` + +Broaden to the DOM/migration browser files for shared primitives or adapters, +and to the existing package/installed checks for asset or packaging changes. +Repository `npm test` and `npm run harness` remain handoff gates; report actual +failures and unrun checks rather than calling the focused lane a full green. + +## Design rules + +Use the host-aware toolkit tokens and type ramp. Keep labels and controls +normal-weight, headings modest, action labels short, and related actions aligned. +Hide unavailable optional surfaces rather than leaving empty gaps. Keep real +loading, empty, error and unavailable states distinct. Preserve drafts, focus +and selection when updating. Status must be expressed in text, not color alone. + +### Follow the host theme + +Load `ui/styles.css` before `ui/base.css`. All components use the existing +host-backed `--canvas-*` aliases and host font/type tokens, including native +choices, code blocks and top-layer panels. Host token changes apply live through +CSS; the provider adds no fixed light/dark palette or generated theme stylesheet. +Do not add a fixed palette, theme attributes or the optional CoreAI profile just +to use these components. The design patterns do not replace the host's theme. + +The host must deliver its documented CSS tokens to the canvas document. +Without them, the toolkit uses its existing light/dark fallbacks; a standalone +Browser preview does not prove host token delivery. For non-DOM renderers, use +`readCanvasTheme` / `observeCanvasTheme` from `ui/theme` instead of copying colors. + +These independent implementations follow reviewed canvas design patterns; +they are not an importer for a prototype schema. Product routes, queries, +authorization, persistence, translation, clipboard policy and host capabilities +remain with the caller. + +### Reference coverage + +The full 49-component inventory is reviewed, not just the destination form. +Current treatments include the #304 form, #305 composer, #325 browser-style +navigation, #328 inset source tabs and #329 destination palette. Authoring +provenance and intentional adaptations are recorded in `src/ui/base.source.json`; +frozen browser references protect the declared geometry and styling. + +Compact controls and bounded surfaces use Fluent Headless interactions and documented +semantic host tokens. Host-private shortcuts, stores and window hooks are not +canvas APIs and are not imported. + +**Reference treatments:** chrome, cards, forms, destination rows, inset tabs, +composer, progress, metadata and tables follow the corresponding canvas patterns. +**Themed additions:** generic toolbars, Counter, Skeleton, Blankslate and +overlay/layout compositions follow the same type, spacing, action and state +standards, but do not all have an identically named Alex component. SplitPane's +Fluent Headless Slider and inline DetailPanel intentionally do not reproduce a product's +floating pane, persisted size or routing. Pattern coverage is not whole-screen +pixel identity. Legacy `create*` factories remain compatibility APIs, not the +reference-matched Fluent Headless implementation. + +## Navigation and layout + +| Component | Use / how | Avoid | +| --- | --- | --- | +| `CanvasShell` | Page-layout wrapper for `header`, main `children` and `footer`; it is not a header widget. Give the wrapper the available height when the footer must sit at the bottom. | Comparing an entire page composition with a header alone, or forcing every canvas into one page layout. | +| `CanvasHeader` | Product `title`, selected `context`, `navigation` and a few `actions`. Supply `browse` Button props and `history` for the current browser-style header; omitting both keeps centered navigation. | Putting every destination or raw account metadata in the header. | +| `CanvasFooter` | Real `links`, `language` control and `version` slots. Handle language errors in the app. | Placeholder links, fake translation, fixed overlays covering content. | +| `NavigationControls` | Supply `browse`, real history availability and Jump callbacks. `variant="browser"` uses chevrons; `layout="header"` puts caller-owned identity children on the left and Jump/`actions` on the right. Render the page scope below. Localize labels and only show registered `shortcutLabel`s. | Treating shortcut hints as registered shortcuts, or inventing history. | +| `ProductNavigation` | Grouped sidebar destinations: `items`, optional `groups`, `current`, `onNavigate(event, value)`. Use `variant="inline"` for a small wrapping set. Caller controls sidebar/overlay visibility. | Using command search as the only way to discover core tasks; application-wide modal focus traps. | +| `CommandPalette` | Visible `commands`, `onCommand` and a `trigger` or controlled `open`. Type to filter; Down/Up enters results, Fluent owns arrow navigation, Home/End jumps to boundaries, Enter selects and Escape restores trigger focus. Localize copy. | Hard-coded routes, mouse-only entry, nested search dropdowns, or interpreting selection as authorization. Global shortcuts remain caller-owned. | +| `DetailPanel` | Bordered secondary surface with a divided header/body, `title`, optional `status`/`statusTone`, `actions`, `footer` and `onClose`. Compose activity content as children; the caller places and opens the panel. | Interrupting tasks that require a modal, hiding essential inputs, or assuming the component persists/docks itself. | +| `SplitPane` | `first`/`second` slots, 10–90% slider resizing; control with `ratio`/`onRatioChange`, or leave uncontrolled. Stacks when narrow. | Resizing a simple one-column screen or persisting widths without caller intent. | +| `ResizeHandle` | A Fluent Headless Slider with a meaningful `label`, `value` and `onChange`; the native range input owns arrows/Home/End. | Decorative separators with no working resize behavior or an unlabeled slider. | +| `ListDetail` | Supply `items: [{value,title,description?,group?,meta?,icon?}]`, optional controlled `selectedValue`/`onSelect`, and `children(value)` for details. Includes local text search, collapsible groups, selection and narrow Back flow. `list` preserves custom slots. | Fetching or paging remote data, silently persisting selection, or treating local search as a server-wide query. | +| `Tabs` | Related views of one entity; supply stable item IDs/content and selected state. Use `variant="inset"` for the compact source strip, otherwise underline tabs. Fluent owns arrow keys; disabled tabs remain focusable but cannot select. | A replacement for product-wide task navigation or sequential steps. | +| `Disclosure` | Optional metadata: `title`, `children`, `defaultOpen`. Use `keepMounted` for form drafts or content that must remain readable while collapsed. Fluent Accordion owns expansion; the adapter hides retained collapsed content. | Concealing required inputs or critical failures by default. | +| `InlineEditor` | One controlled `value`, `onChange`, `onSave`, `onCancel`, `pending` and `error`. Keep the draft until accepted. | Multi-field workflows, silent autosave, or using it as an authorization boundary. | + +## Forms and choices + +| Component | Use / how | Avoid | +| --- | --- | --- | +| `FormField` | Fluent Headless Field links `label`, `hint`, required state and `error` to a child control. | Using placeholders instead of labels or wrapping non-labelable custom widgets. | +| `TextInput` | One-line `value`/`defaultValue`, `onChange(event, {value})`; compose in FormField for hints/errors. | Long prose, secrets without a dedicated secure input, or validation on every keystroke without a reason. | +| `Textarea` | Multiline drafts with native textarea props and `onChange(event, {value})`; compose in FormField. | Treating Enter as submission; use PromptComposer for messages. | +| `Select` | A small, known single-choice set with native behavior; `value` must match an option. | Very large searchable catalogs; use SelectPanel. | +| `Checkbox` | Independent yes/no `checked` state; use toolkit `"mixed"` for partial selection. | Mutually exclusive choices or disguising an immediate write as harmless selection. | +| `CheckboxGroup` | Labeled fieldset containing Checkbox children with caller-owned checked state. Use `hint` for group guidance and `variant="cards"` for described choices. | A long catalog that needs search. | +| `RadioGroup` / `ChoiceGroup` | Labeled group with Radio children, `value` and `onChange`; use `hint` and per-Radio `description` to explain choices. | A command menu or unrelated action buttons. | +| `Switch` | Independent on/off setting: label above, state beside the track, optional `hint` below. Supports controlled or default state, `size="small"` and localized `onLabel`/`offLabel`. | Selecting a mode from several alternatives or implying a remote change succeeded. | +| `SelectPanel` | Trigger opens a titled, searchable option panel. Supply `options: [{value, label, description?, disabled?}]`, `selectedOptions` and `onOptionSelect`; use `multiselect` only for independent selections. Fluent Listbox owns selection and active-descendant navigation inside the popover. | Network discovery inside the control; present loading/errors outside it, not as fake empty options. | +| `InputWithAction` | `label`, `input` props and `action` Button props; stacks below 360px. `preview` is linked to the input. The caller handles picker cancellation, pending/errors and stale results. | Assuming filesystem access, folder drag/drop, or treating cancellation as failure. | + +## Content and actions + +| Component | Use / how | Avoid | +| --- | --- | --- | +| `SectionHeader` | Modest title, optional description/count, aligned section actions. | Nested page heroes or making every label bold. | +| `ActionGroup` | A small wrapping group of related native buttons. | Giving unrelated actions one ambiguous group label. | +| `ActionToolbar` | A related command set with Fluent-owned focusgroup navigation and arrow keys. | Ordinary forms whose controls need independent Tab stops. | +| `SplitButton` | Joined primary `button` and separate menu trigger; menu children use `MenuItem`. Name icon-only buttons and localize `menuLabel`. Fluent owns menu keys, dismissal and focus restoration. | Opening the menu by executing the primary action, or using menu selection as execution approval. | +| `ActionCard` | A short next action or prompt suggestion; optional decorative icon, compact/joined styles. | Nested interactive controls or selecting a value without choice semantics. | +| `Badge` / `Tag` | Short status or wrapping neutral metadata; supply text children. | Color-only severity, paragraphs, or action buttons disguised as status. These tags do not have removal behavior. | +| `Counter` | `count` and an accessible label, including zero. | Unknown totals represented as zero. | +| `MetadataList` | Responsive horizontal tiles with each label above its value; `items: [{label, value}]` keeps definition-list semantics. | Editable forms or aligned label/value rows; use `KeyValueList` for those read-only rows. | +| `KeyValueList` | Compact definition-list rows, label left and value right. Values may be React nodes. | Comparing entities or using a row layout when metadata should scan horizontally. | +| `CodeBlock` | Preserved read-only text as children; optional `title`, `language`, accessible `label` and header `actions`. Caller supplies reviewed copy/run callbacks; omitting header props keeps a plain code region. | Automatically executing code, unsanitized HTML, or assuming clipboard permission. | +| `OutputPanel` | Read-only text with a visible `label`, optional actual `status`, supporting `description` and localized `emptyMessage`. Status is announced separately from the log. | A terminal emulator, executing commands, unredacted secrets, or inventing success. | +| `Table` | Compose TableHeader, TableBody, TableRow and cells. Caller supplies actual sorted rows and any actions. | Spreadsheet editing, virtualization, nested controls on clickable rows, or sorting partial remote data. | + +The shared form styling follows the Functions #304 treatment: normal-weight +caption labels, 32px-minimum controls, 6px button corners, wrapping neutral tags +and an 8px input/action gap. Colors and type ramps still come from the host. +Required fields retain native/ARIA semantics without adding an asterisk to the label. +For a multi-field destination summary, compose a separate caption/value block +after the fields and link its ID from both inputs' `aria-describedby`; hide it +until complete. Keep path formatting, readiness rules and end-aligned page +actions in the consuming canvas. The browser gallery includes this composition. +This is a reference for those patterns, not a claim of pixel parity for every +SRE or Functions screen. + +## Feedback and interaction + +| Component | Use / how | Avoid | +| --- | --- | --- | +| `PromptComposer` | Bordered autosizing draft with circular Send action: controlled `value`, `onChange`, explicit `onSubmit`, `pending`, `error` and optional extra `actions`. Its `label`/`submitLabel` remain accessible. Enter inserts a newline; clear only after accepted submission. | Sending from example-card selection or clearing before accepted submission. | +| `Spinner` | A short unknown-duration wait with a meaningful accessible label. | Showing it indefinitely without recovery or implying measured progress. | +| `ProgressBar` | Real 0–1 progress (or explicit `max`); omit value for indeterminate work. | Fabricated percentages or time estimates. | +| `Skeleton` | Wrap SkeletonItem children for an initial load; give the region an accessible label. | Masking errors or replacing stable content on every refresh. | +| `MessageBar` | Concise information, success, warning or error using the matching intent. Long paths and URLs wrap without text loss. | Long transcripts or reporting unavailable reads as “nothing found.” | +| `Blankslate` | A genuine empty state with one useful next action. | Displaying it after a failed or cancelled read. | +| `FilterPill` | One applied/available filter with caller-owned state. | Status decoration with no filtering behavior. | +| `FilterToolbar` | Supply `search` input props, `facets`, controlled `values`/`onFiltersChange` and `sort` options. Search stays visible; controls wrap when narrow. `resultCount` announces caller results. Children-only usage retains ActionToolbar behavior. | Implicit remote filtering/sorting or representing local matches as complete server results. | +| `Dialog` | Bounded modal with divided header/body/footer: `title`, optional `subtitle`, `trigger`, `children`, `actions` and Close. Use `size` for small/medium/large/xlarge; wrap footer dismiss buttons in DialogTrigger. Fluent and native dialog behavior own focus and Escape. | Routine details better shown inline or in DetailPanel; a second custom focus trap. | +| `Popover` | Compose PopoverTrigger and PopoverSurface for anchored interactive content. | Essential information available only on hover. | +| `MenuButton` | A short list of actions with keyboard selection. | Large searchable catalogs or product navigation. | +| `Tooltip` | Short supplementary description for an existing trigger. | Required instructions, interactive content or replacing an accessible name. | + +## Legacy factory migration + +The following examples and lifecycle notes are for existing `ui/components` +callers, not the React props above. They remain supported during migration. + +```js +import { + createTextInput, createDisclosure, createMetadataList, createInputWithAction, +} from "@microsoft/canvas-toolkit/ui/components"; + +const name = createTextInput({ + mount, label: "App name", value: draft.name, + onChange: value => { draft.name = value; }, +}); +const details = createMetadataList({ + items: [{ id: "language", label: "Language", value: "JavaScript" }], +}); +const disclosure = createDisclosure({ + mount, label: "App details", content: details.element, +}); +const folder = createInputWithAction({ + mount, label: "Parent folder", value: draft.folder, actionLabel: "Browse", + onChange: value => { draft.folder = value; }, + onAction: () => host.chooseFolder(), // Real host adapter; cancellation returns null. +}); + +// On the owning view's unmount, including a React effect cleanup: +name.dispose(); folder.dispose(); disclosure.dispose(); details.dispose(); +``` + +For navigation, keep the two responsibilities separate: + +```js +const navigation = createProductNavigation({ + mount, container: shellElement, label: "Tasks", currentId: route, + groups: [{ label: "Work", items: destinations }], + onSelect: id => navigate(id), +}); +const commands = createCommandPalette({ + items: availableCommands, + onSelect: id => executeReviewedCommand(id), +}); +// The app wires its Jump trigger to commands.open(). +``` + +`onSelect`, `onChange` and synchronous action callbacks report intent; the caller +owns failures from its own operations. Components with built-in async workflows +(composer, palette, inline editor, browse, copy and footer language selection) +display rejected operations and invalidate stale completions when replaced or +disposed. Other controls do not start background work. + +## Migration boundaries + +- Adopt one section at a time; keep product state, drafts, routes and operation + approvals where they already live. Replace presentation, not business logic. +- Public updates are shallow patches. Supply a complete nested configuration + when replacing groups/options; preserve stable IDs and explicitly adjust + selection when removing the selected tab or option. +- Tables never query or sort implicitly. Pass the actual collection, sort state + and loading/error state. Output text should already be appropriately redacted. +- Native dialog and Popover APIs are required by their components; unsupported + browsers throw rather than pretending the interaction works. +- Keep app-wide keyboard shortcut registration at the consumer boundary. + Components only handle keys while focus is within their own surface. diff --git a/plugins/canvas-authoring/skills/create-canvas-app/templates/azure-resource-groups/README.md b/plugins/canvas-authoring/skills/create-canvas-app/templates/azure-resource-groups/README.md index 5eceb26..91105a8 100644 --- a/plugins/canvas-authoring/skills/create-canvas-app/templates/azure-resource-groups/README.md +++ b/plugins/canvas-authoring/skills/create-canvas-app/templates/azure-resource-groups/README.md @@ -52,13 +52,16 @@ does not demonstrate live Azure access. | File | Purpose | | --- | --- | -| `src/browser/` | UI, styles and subscription picker. | +| `src/browser/` | UI built from toolkit `ui/dom` controls, shared styles and the subscription picker. | | `src/resource-groups.mjs` | Azure reader copied from the toolkit example. | | `src/domain.mjs` | Shared actions, scope, cancellation and stale-result checks. | | `src/canvas.mjs` | Per-panel ownership, transport and assets. | | `src/toolkit.mjs` | Native adapter and shutdown. | | `scripts/build.mjs` | Node/browser builds, toolkit assets and build ID. | +Reuse toolkit components (`ui/dom`, `ui/base.css`) for new controls instead of custom +buttons or alerts. The starter sends no product telemetry. + Start with a local location filter, preserving raw IDs and loaded counts. Keep the generated asset layout: bundling JavaScript alone does not copy icons. Closing a panel or restarting the provider clears its state and cancels work; diff --git a/plugins/canvas-authoring/skills/create-canvas-app/templates/azure-resource-groups/src/browser/app.css b/plugins/canvas-authoring/skills/create-canvas-app/templates/azure-resource-groups/src/browser/app.css index 7cee41f..0037a52 100644 --- a/plugins/canvas-authoring/skills/create-canvas-app/templates/azure-resource-groups/src/browser/app.css +++ b/plugins/canvas-authoring/skills/create-canvas-app/templates/azure-resource-groups/src/browser/app.css @@ -1,8 +1,7 @@ main { max-width: 42rem; margin: 0 auto; padding: 24px; } -h1 { font-size: var(--text-title-large, 26px); line-height: var(--leading-title-large, 32px); } +h1 { font-size: var(--text-title-medium, 20px); line-height: var(--leading-title-medium, 28px); font-weight: 600; } p { color: var(--canvas-muted); } -.source { color: var(--canvas-fg); font-weight: 600; } +.source { color: var(--canvas-fg); } .note { font-size: 12px; } li { padding: 8px 0; overflow-wrap: anywhere; } -.controls { display: flex; gap: 8px; flex-wrap: wrap; } -#error { color: var(--canvas-danger); } +.controls { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } diff --git a/plugins/canvas-authoring/skills/create-canvas-app/templates/azure-resource-groups/src/browser/app.mjs b/plugins/canvas-authoring/skills/create-canvas-app/templates/azure-resource-groups/src/browser/app.mjs index d2ba36c..38c94f8 100644 --- a/plugins/canvas-authoring/skills/create-canvas-app/templates/azure-resource-groups/src/browser/app.mjs +++ b/plugins/canvas-authoring/skills/create-canvas-app/templates/azure-resource-groups/src/browser/app.mjs @@ -1,6 +1,8 @@ import "@microsoft/canvas-toolkit/ui/styles.css"; +import "@microsoft/canvas-toolkit/ui/base.css"; import "@microsoft/canvas-toolkit/ui/subscription-picker.css"; import { createAzureSubscriptionPicker } from "@microsoft/canvas-toolkit/ui/azure-subscription-picker"; +import { Button, MessageBar, mountCanvasComponent } from "@microsoft/canvas-toolkit/ui/dom"; import { formatAzureLocation } from "@microsoft/canvas-toolkit/ui/locations"; import "./app.css"; @@ -10,16 +12,24 @@ let model; let pending = 0; let ready = false; let fileBuild; +const errorBar = mountCanvasComponent(element("error"), MessageBar, { intent: "error" }); function showError(error) { - element("error").textContent = error.message; + errorBar.update({ children: error.message }); element("error").hidden = false; } +const buttons = Object.fromEntries([ + ["profile", "Load profile", "secondary", "reload_profile"], + ["list", "List resource groups", "primary", "list_resource_groups"], + ["cancel", "Cancel", "secondary", "cancel"], +].map(([id, label, appearance, action]) => [id, mountCanvasComponent(element(id + "-slot"), Button, { + id, appearance, disabled: true, children: label, onClick: () => act(action, {}).catch(showError), +})])); function controls() { const loading = model?.status === "loading"; - element("profile").disabled = !ready || pending > 0 || model?.status === "loading-profile"; + buttons.profile.update({ disabled: !ready || pending > 0 || model?.status === "loading-profile" }); element("scope").disabled = !ready || model?.profile !== "connected" || model?.status === "loading-profile"; - element("list").disabled = !ready || pending > 0 || !model?.scope; - element("cancel").disabled = !ready || !loading; + buttons.list.update({ disabled: !ready || pending > 0 || !model?.scope }); + buttons.cancel.update({ disabled: !ready || !loading }); } const picker = createAzureSubscriptionPicker({ id: "builder-subscription", trigger: element("scope"), selectionMode: "single", @@ -86,9 +96,6 @@ async function act(name, input) { controls(); } } -for (const [id, name] of [["profile", "reload_profile"], ["list", "list_resource_groups"], ["cancel", "cancel"]]) { - element(id).addEventListener("click", () => act(name, {}).catch(showError)); -} const events = new EventSource(new URL("events", location.href)); events.addEventListener("change", refresh); events.onopen = () => { element("connection").textContent = "Connected"; }; @@ -97,7 +104,9 @@ events.onerror = () => { controls(); element("connection").textContent = "Connection lost; reconnecting..."; }; -window.addEventListener("pagehide", () => { events.close(); picker.destroy(); }); +window.addEventListener("pagehide", () => { events.close(); picker.destroy(); errorBar.dispose(); + for (const button of Object.values(buttons)) button.dispose(); +}); try { fileBuild = await request("build.json"); await refresh(); diff --git a/plugins/canvas-authoring/skills/create-canvas-app/templates/azure-resource-groups/src/browser/index.html b/plugins/canvas-authoring/skills/create-canvas-app/templates/azure-resource-groups/src/browser/index.html index 6b07599..93b6b04 100644 --- a/plugins/canvas-authoring/skills/create-canvas-app/templates/azure-resource-groups/src/browser/index.html +++ b/plugins/canvas-authoring/skills/create-canvas-app/templates/azure-resource-groups/src/browser/index.html @@ -13,14 +13,14 @@

Azure resource groups

Loading source...

A small canvas built from the authoring plugin and toolkit. No automatic login or resource query.

- + - - + +

No subscription selected.

Not loaded

- +

    Connecting...

    Build loading...