From d24ad2fc9778372e67d45746ab5a8a624076d70d Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Mon, 12 Oct 2026 12:26:32 +1100 Subject: [PATCH 01/14] fix(onboarding): distinguish sign-in clicks and define account copy --- COPY.md | 21 ++++++++++++++++--- app/plugins/signup-clicks.client.ts | 17 +++++++++++++++ .../app/composables/useSignupEvents.ts | 2 +- .../identity/server/api/events/signup.post.ts | 4 ++-- .../identity/server/policies/signup-event.ts | 7 +++++++ shared/checkin-external.ts | 2 +- shared/signup-analytics.ts | 15 +++++++++++++ test/unit/signup-analytics.test.ts | 19 ++++++++++++++++- test/unit/signup-event-endpoint.test.ts | 10 +++++++++ 9 files changed, 89 insertions(+), 8 deletions(-) create mode 100644 app/plugins/signup-clicks.client.ts create mode 100644 layers/identity/server/policies/signup-event.ts diff --git a/COPY.md b/COPY.md index ae22d546..39d3ff29 100644 --- a/COPY.md +++ b/COPY.md @@ -51,11 +51,11 @@ These exact strings. Do not paraphrase them per page. | Demo groups | Films and launch videos · Landing pages · UI components · Diagrams and explainers | guidance only: `DEMO_GROUPS` in `shared/demo-groups.ts`: the `/skills/demos` group headings and their lines | | Writing demo | Writing · Articles, READMEs, and PR descriptions. · Files · Updated · Diff · Markdown · No newline at end of file | guidance only: writing support on the existing demos board. Show file buttons with Markdown icons and one Diff toggle with an icon. Show the updated document by default. Diff compares the original seed and updated Markdown with line numbers and syntax highlighting. Fit short documents to their content and cap long documents. Keep the no-Skill baseline in recording data for later evaluations. No decorative text. | | Writing demo example | Example data · Example data. No Skills were run. | guidance only: each known writing Skill uses `/skills/demos///`, with `/{name}` as its heading. All candidate rows share handwritten UI fixtures. Keep the disclosure visible. Show no recording metadata. Reuse the demo footer with Skill links, Run it yourself, and Copy demo link. The run prompt supplies the original fixture documents. README and PR documents use GitHub-style Markdown with the shared Skill renderer and rangi highlighter. Diff toggles the Markdown comparison on and off. | -| Demo actions | Run it yourself · Agent prompt · Terminal · Copy Agent prompt · Copy command · Copied · Copy demo link · Copied demo link | guidance only: demo stages. The Agent prompt includes the recorded task. The terminal option loads the Skill. Run is hidden on phones. | +| Demo actions | Run it yourself · Agent prompt · Terminal · Copy Agent prompt · Copy command · Copied · Copy demo link · Copied demo link | guidance only: demo stages. The Agent prompt includes the recorded task. The terminal option loads the Skill. Run is available on phones. | | Demo stage | Open the demo | guidance only: the demo stage link. The hero shows the output, author row, and this link only. On the homepage it opens the demo page; on a demo page it opens the Skill page Demo panel | | Home demo carousel | Previous demo · Next demo · {n} of {total} | guidance only: the manual homepage demo controls. The position is live text for screen readers. | -| Demo page prompt | Prompt | guidance only: the selected demo page keeps its recorded task in an expandable disclosure beside the model and effort, above the output frame. | -| Demo panel | Demo · Prompt · Open live demo · Open in a new tab · Run it yourself · All demos | guidance only: the Skill page Demo panel. "Show screenshots" replaces "Open live demo" while the live output shows. "All demos" opens the demo page. It hides for a Skill page only demo, and while the Skill holds a run check flag | +| Demo page prompt | Prompt | guidance only: full and compact demo stages keep the recorded task in a closed disclosure above the output frame. | +| Demo panel | Demo · Prompt · Open live demo · Open in a new tab · Run it yourself · All demos | guidance only: the Skill page Demo panel. Prompt and setup start in a closed disclosure. "Show screenshots" replaces "Open live demo" while the live output shows. "All demos" opens the demo page. It hides for a Skill page only demo, and while the Skill holds a run check flag | | Demo token usage | {n} tokens · Input: {input}; cached input: {cached}; output: {output} | guidance only: recording metadata. Counts come from recorded usage. Cached input is included in input. Omit unavailable counts. | | Demo provenance | {Agent logo} {model}, {effort} effort | guidance only: the recording line on the Skill page panel and the homepage stage. Screen readers and the title get "Recorded with {agent}, {model}, {effort} effort". Omit effort when the recording has no evidence for it. No date and no GitHub link: the Skill page is where visitors inspect a Skill | | Demo outdated | Recorded on an older version of this Skill. | guidance only: under the Demo panel when the Skill moved past the recorded commit | @@ -180,6 +180,21 @@ The weekly card uses `Trending skills this week` as its title and links to the t Each row links the Skill, the dev count, and `Source`. Keep the run command beside the source link. Use `7-day social mentions · {date}` as the footer. Format the UTC date as `2 Oct 2026`. +## Account entry and setup + +| Surface | Canonical strings | Use | +| --- | --- | --- | +| Login | Sign in to skilld · Use your GitHub account. New here? The same step creates your account. · Continue with GitHub | One account entry creates an account or signs in. Preserve the requested action and return page. | +| Login benefits | Your skills · Like Skills to find them again in your account. · Watch for changes · Watch the Repositories you use and see what changed. · Email updates · Choose the weekly, monthly watched changes, or neither. | Three account benefits. Running Skills remains anonymous. | +| Login permissions | We ask for your public profile and email address. | Keep permission scope separate from account benefits. | +| Login intent | Sign in to like this Skill. · Sign in to watch this Repository. · Sign in to watch this collection. | Explain the action carried into authentication. | +| Login meta | Sign in with GitHub to keep liked Skills, watch for changes, and choose your emails. | Login page description. | +| Like needs a Skill | Open a Skill before liking it. | A Repository with several Skills cannot imply which Skill to like. | +| Star import | We check up to 1,000 starred GitHub Repositories against the registry. Choose which to watch. Email updates are optional. | Match registry coverage, regardless of Repository name. | +| Star import empty | Find Skills from your GitHub stars · Import stars · None of the checked Repositories have Skills in the registry yet. · Browse curated Skills, then like one to keep it in your account. | Offer discovery when imported stars have no matching Skills. | +| Star import partial | Checked the first 1,000 GitHub stars. Some starred Repositories were not checked. | The page limit leaves coverage partial. | +| Account without likes, with watches | Your watched Repositories are ready · {n} watched {Repository/Repositories}. Like a Skill to keep it here. · Browse skills · View watched Repositories | Show existing account value when the liked Skills view is empty. | + ## Register by context | Context | Register | Example | diff --git a/app/plugins/signup-clicks.client.ts b/app/plugins/signup-clicks.client.ts new file mode 100644 index 00000000..f2d7b6a8 --- /dev/null +++ b/app/plugins/signup-clicks.client.ts @@ -0,0 +1,17 @@ +import { useEventListener } from '@vueuse/core' +import { signupClickEvent } from '#shared/signup-analytics' + +export default defineNuxtPlugin(() => { + const recordSignup = useSignupEvents() + useEventListener(document, 'click', (event) => { + // Page loads, prefetches, and programmatic DOM clicks are not click evidence. + if (!event.isTrusted || !(event.target instanceof Element)) + return + const link = event.target.closest('a[href]') + if (!link) + return + const point = signupClickEvent(link.href, window.location.origin) + if (point) + recordSignup(point) + }) +}) diff --git a/layers/identity/app/composables/useSignupEvents.ts b/layers/identity/app/composables/useSignupEvents.ts index 92613b0c..6a531589 100644 --- a/layers/identity/app/composables/useSignupEvents.ts +++ b/layers/identity/app/composables/useSignupEvents.ts @@ -3,7 +3,7 @@ import type { SignupBrowserEvent } from '#shared/signup-analytics' export function useSignupEvents() { const { loggedIn } = useUserSession() return (body: SignupBrowserEvent): void => { - if (!loggedIn.value) + if (!loggedIn.value && body.stage !== 'oauth') return // Keep the action independent of analytics and survive same-tab redirects. void $fetch('/api/events/signup', { method: 'POST', body, retry: false, keepalive: true }).catch(() => { diff --git a/layers/identity/server/api/events/signup.post.ts b/layers/identity/server/api/events/signup.post.ts index dd17c4da..a5f98ce8 100644 --- a/layers/identity/server/api/events/signup.post.ts +++ b/layers/identity/server/api/events/signup.post.ts @@ -1,12 +1,12 @@ import { defineApiHandler } from '#shared/server/handler' import { SignupBrowserEvent } from '#shared/signup-analytics' import { identityMutationResponseSchema } from '../../../shared/contracts/account' -import { authenticated } from '../../policies/authenticated' +import { signupEventAllowed } from '../../policies/signup-event' import { emitSignupEvent } from '../../utils/signup-analytics' export default defineApiHandler({ schema: SignupBrowserEvent, - policy: [authenticated], + policy: [signupEventAllowed], response: identityMutationResponseSchema, handler: ({ event, body }) => emitSignupEvent(event, body), presenter: () => ({ ok: true as const }), diff --git a/layers/identity/server/policies/signup-event.ts b/layers/identity/server/policies/signup-event.ts new file mode 100644 index 00000000..35e4793a --- /dev/null +++ b/layers/identity/server/policies/signup-event.ts @@ -0,0 +1,7 @@ +import type { Policy } from '#shared/server/handler' +import type { SignupBrowserEvent } from '#shared/signup-analytics' +import { authenticated } from './authenticated' + +/** Anonymous browsers may record clicks, but cannot claim account setup outcomes. */ +export const signupEventAllowed: Policy = context => + context.body.stage === 'oauth' || authenticated(context) diff --git a/shared/checkin-external.ts b/shared/checkin-external.ts index efedf2fd..456482ad 100644 --- a/shared/checkin-external.ts +++ b/shared/checkin-external.ts @@ -17,7 +17,7 @@ Interpret this site evidence: - \`skilld.baseline\` result evidence records the window against the daily cadence. \`stale\` means the saved baseline is old; report the \`gapHours\` figure and compare rates per day, never as overnight step changes. The shared CLI rejects invalid or future state before collecting evidence. Repair the state or supply an explicit \`--since\` boundary. - Use failed-job \`first_failed_at\` and \`last_failed_at\`, plus \`d1.recentJobBatches\` and \`d1.registryMaintenance\`, to distinguish an active incident from a recovered burst. Do not call a window clean because the latest batch passed, or active because an older batch failed. - \`d1.inventory.broken_repos\` is known cumulative inventory. Review \`d1.pipeline.newly_broken_repos_total\`, but gate health on \`d1.pipeline.newly_broken_repos_impacted\`. A source removal is impacting when it still backs a skill or appears in a star, subscription, or collection. - - \`skilld.analytics\` holds command copies from Analytics Engine, which is the activation signal. Report \`commandCopies.run\` and \`commandCopies.install\` apart: a run copy reads a Skill once, an install copy keeps it. Analytics Engine samples, so treat a small day as a range, not an exact count. \`signup.stages\` counts sign-in and setup events by entry and outcome. These are attempts, not unique users or a joined conversion funnel. Report failures and email opt-outs separately. Missing events cannot prove abandonment. + - \`skilld.analytics\` holds command copies from Analytics Engine, which is the activation signal. Report \`commandCopies.run\` and \`commandCopies.install\` apart: a run copy reads a Skill once, an install copy keeps it. Analytics Engine samples, so treat a small day as a range, not an exact count. \`signup.stages\` counts sign-in and setup events by entry and outcome. OAuth \`clicked\` counts browser interactions. OAuth \`started\` counts server requests, including crawlers and direct requests. Neither proves a human conversion. These are attempts, not unique users or a joined conversion funnel. Report failures and email opt-outs separately. Missing events cannot prove abandonment. - \`skilld.signing-key\` Warn or Fail means Artifact signing ends soon. Quote \`signingEndsAt\` and \`daysLeft\`, and point to \`docs/runbooks/signing-key-rotation.md\`. After that date every \`skilld run\` fails. - AI cost is only the recorded batch estimate. Unmeasured services are unknown, not $0.`, }, diff --git a/shared/signup-analytics.ts b/shared/signup-analytics.ts index bfd1b3c1..19dd747c 100644 --- a/shared/signup-analytics.ts +++ b/shared/signup-analytics.ts @@ -2,6 +2,7 @@ import type { AnalyticsDataPoint } from './analytics' import { z } from 'zod' const browserStages = [ + z.object({ stage: z.literal('oauth'), entry: z.enum(['direct', 'return-page', 'cli', 'like-skill', 'watch-skill', 'watch-collection']), outcome: z.literal('clicked') }).strict(), z.object({ stage: z.literal('discover'), entry: z.literal('onboarding'), outcome: z.enum(['viewed', 'imported', 'import-failed', 'continued', 'skipped', 'watch-failed']) }).strict(), z.object({ stage: z.literal('email'), entry: z.enum(['onboarding', 'dashboard']), outcome: z.enum(['viewed', 'failed', 'completion-failed']) }).strict(), z.object({ stage: z.literal('email'), entry: z.enum(['onboarding', 'dashboard']), outcome: z.literal('saved'), choice: z.enum(['none', 'weekly', 'monthly', 'both']) }).strict(), @@ -24,6 +25,20 @@ export function signupEntry(action: string, returnTo: string): SignupEntry { return returnTo ? 'return-page' : 'direct' } +/** Classify a clicked sign-in link without recording its path or parameters. */ +export function signupClickEvent(href: string, origin: string): Extract | null { + if (!URL.canParse(href, origin)) + return null + const url = new URL(href, origin) + if (url.origin !== origin || url.pathname !== '/auth/github') + return null + return { + stage: 'oauth', + outcome: 'clicked', + entry: signupEntry(url.searchParams.get('action') ?? '', url.searchParams.get('return_to') ?? ''), + } +} + export function signupEmailChoice(weekly: boolean, monthly: boolean): 'none' | 'weekly' | 'monthly' | 'both' { return weekly ? (monthly ? 'both' : 'weekly') : (monthly ? 'monthly' : 'none') } diff --git a/test/unit/signup-analytics.test.ts b/test/unit/signup-analytics.test.ts index a2a38965..12df1f59 100644 --- a/test/unit/signup-analytics.test.ts +++ b/test/unit/signup-analytics.test.ts @@ -1,6 +1,23 @@ import { summarizeSignupEvents } from '../../checks/_helpers/signup-analytics' import { recordSignupEvent } from '../../layers/identity/server/utils/signup-analytics' -import { SignupBrowserEvent, signupDataPoint, signupEmailChoice, signupEntry } from '../../shared/signup-analytics' +import { SignupBrowserEvent, signupClickEvent, signupDataPoint, signupEmailChoice, signupEntry } from '../../shared/signup-analytics' + +it('records a GitHub sign-in click using only fixed labels', () => { + expect(signupClickEvent('/auth/github?return_to=%2Fgh%2Fowner%2Frepo&action=like-skill', 'https://skilld.dev')).toEqual({ + stage: 'oauth', + outcome: 'clicked', + entry: 'like-skill', + }) + expect(signupClickEvent('/auth/github?return_to=%2Fcli%2Fauthorize%3Fuser_code%3DSECRET', 'https://skilld.dev')).toEqual({ + stage: 'oauth', + outcome: 'clicked', + entry: 'cli', + }) +}) + +it.each(['/login', 'https://other.example/auth/github', 'javascript:alert(1)', '/auth/github/callback', 'http://['])('ignores links outside the sign-in action: %s', (href) => { + expect(signupClickEvent(href, 'https://skilld.dev')).toBeNull() +}) it('classifies CLI sign-in without storing its authorization parameters', () => { expect(signupEntry('', '/cli/authorize?user_code=SECRET')).toBe('cli') diff --git a/test/unit/signup-event-endpoint.test.ts b/test/unit/signup-event-endpoint.test.ts index bdc4e0b4..829b37bb 100644 --- a/test/unit/signup-event-endpoint.test.ts +++ b/test/unit/signup-event-endpoint.test.ts @@ -29,6 +29,16 @@ it('refuses counts from a signed-out request', async () => { expect(mocks.write).not.toHaveBeenCalled() }) +it('accepts an anonymous sign-in click without granting access to setup events', async () => { + await expect(request({ stage: 'oauth', entry: 'like-skill', outcome: 'clicked' }, false)).resolves.toEqual({ ok: true }) + expect(mocks.write).toHaveBeenCalledWith({ blobs: ['oauth', '', 'signup', 'like-skill', '', 'clicked', ''], doubles: [0, 0, 1], indexes: ['signup:oauth'] }) +}) + +it('refuses browser claims about server sign-in outcomes', async () => { + await expect(request({ stage: 'oauth', entry: 'direct', outcome: 'succeeded' }, false)).rejects.toMatchObject({ statusCode: 400 }) + expect(mocks.write).not.toHaveBeenCalled() +}) + it('refuses an event carrying an account identifier', async () => { await expect(request({ stage: 'email', entry: 'onboarding', outcome: 'saved', choice: 'none', userId: 42 }, true)).rejects.toMatchObject({ statusCode: 400 }) expect(mocks.write).not.toHaveBeenCalled() From 21fd675fb4b9ef77741c84b91c55a2698c7ff73f Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Mon, 12 Oct 2026 12:22:49 +1100 Subject: [PATCH 02/14] fix(discovery): shorten demo prompts and expose run actions --- app/components/DemoStage.vue | 19 +++++++++++----- app/components/_DemoActions.vue | 1 - app/components/_DemoFooter.vue | 1 + .../registry/app/components/SkillDetail.vue | 9 ++++++++ layers/registry/app/components/_SkillDemo.vue | 22 ++++++++++++++----- test/a11y.nuxt.spec.ts | 9 ++++++++ 6 files changed, 49 insertions(+), 12 deletions(-) diff --git a/app/components/DemoStage.vue b/app/components/DemoStage.vue index 0eec4940..53fa6527 100644 --- a/app/components/DemoStage.vue +++ b/app/components/DemoStage.vue @@ -10,8 +10,8 @@ import DemoWriting from './home-demos/_DemoWriting.vue' /** * One demo on show: how it was recorded, the output in a window one screen * tall (a page that scrolls inside it, or a film that plays while on screen), - * and the Skill's row with its run command. `showPrompt` adds the prompt in - * full, for a list that names the demos without their prompts. `live` puts a + * and the Skill's row with its run command. `showPrompt` adds a disclosure + * for the recorded task. `live` puts a * page demo's real output in the window, in a frame with no same-origin * access; the route serves it under a CSP sandbox too. `opens` picks where * "Open the demo" goes: the demo's own page, or, on that page, the Skill @@ -56,9 +56,14 @@ const liveUrl = computed(() => live && !demo.video && !demo.writing ? demo.liveU