diff --git a/.fleet/design-review-hosted-feedback-20261004.json b/.fleet/design-review-hosted-feedback-20261004.json new file mode 100644 index 00000000..b53f2722 --- /dev/null +++ b/.fleet/design-review-hosted-feedback-20261004.json @@ -0,0 +1,226 @@ +{ + "$schema": "fleet.design-review.v2", + "version": 2, + "project": "swe-interview-prep", + "target": "Existing hover feedback shared form parity", + "surfaceMode": "operate", + "mode": "preserve", + "register": "product", + "context": { + "product": "PRODUCT.md", + "design": "DESIGN.md" + }, + "direction": { + "productSurfaces": { + "scope": "standalone", + "landing": "", + "app": "https://learn.significanthobbies.com/dashboard", + "reason": "Bounded existing application feedback adapter repair; the public login and curriculum surfaces retain their current identity and routes." + }, + "pairedPreview": { + "landing": "", + "app": "" + }, + "source": "existing", + "references": [], + "probes": [], + "selected": "existing-design", + "approval": "not-required", + "ownerEvidence": { + "path": "", + "quote": "" + }, + "supplied": "", + "before": ".fleet-local/evidence/hosted-feedback-20261004/before.png", + "library": { + "strategy": "upstream-first", + "primary": "existing-project-system", + "sources": [ + "https://sassmaker.com/feedback-launcher.js" + ], + "runtime": "existing", + "customReplacement": { + "used": false, + "authorization": "not-required", + "reason": "" + } + }, + "contract": { + "purpose": "SWE Interview Prep helps software engineers build retained artifact-backed understanding through an evidence-gated study and practice loop.", + "purposeSource": "PRODUCT.md and canonical site-health/docs/project-dossiers/swe-interview-prep.yaml purposeContract", + "canonicalPurpose": "SWE Interview Prep is a personal learning OS that turns software-engineering study into retained, artifact-backed understanding.", + "purposeAlignment": "match", + "driftNote": "", + "audience": "Software engineers studying practical mechanisms and interview skills.", + "job": "Send feedback without interrupting active study or requiring an email address.", + "thesis": "Preserve the quiet black engineering workbench and existing bottom-right feedback affordance; use the maintained shared form.", + "system": "Geist typography; neutral near-black surfaces, near-white text, sparse sky focus accents, 44px trigger, existing floating bottom-right placement.", + "signature": "Feedback remains outside focused learning and shared routes so the evidence-gated practice workbench remains uninterrupted.", + "risk": "Lazy hosted first-party module can fail or drift; show retry state and verify actual deployed consent and focus behavior.", + "qualityBar": "Optional email, explicit unchecked consent and privacy link, keyboard entry/trap/Escape/return, one widget root, no duplicate submissions or initial bundle load." + } + }, + "evidence": { + "platform": "web", + "productContinuity": { + "status": "not-applicable", + "reason": "Bounded existing application feedback adapter repair; the public login and curriculum surfaces retain their current identity and routes.", + "reviewer": "", + "report": "artifacts/design/product-continuity.md", + "surfaces": { + "landing": "artifacts/design/landing.png", + "app": "artifacts/design/app.png" + }, + "checks": { + "designSystem": { + "status": "pending", + "observation": "" + }, + "identity": { + "status": "pending", + "observation": "" + }, + "vocabulary": { + "status": "pending", + "observation": "" + }, + "productTruth": { + "status": "pending", + "observation": "" + }, + "handoff": { + "status": "pending", + "observation": "" + } + } + }, + "slopScale": { + "platform": "web", + "posture": "advisory", + "reason": "", + "checkpoints": [ + { + "stage": "iteration", + "status": "scanned", + "command": "node /Users/sarthak/Desktop/fleet/saas-maker/tooling/scripts/slop-score.mjs http://127.0.0.1:8794/dashboard --json", + "report": ".fleet-local/evidence/hosted-feedback-20261004/slop-full-app-iteration.json", + "findingsReview": "Geist, permanent dark workbench, and four-item path group are intentional existing DESIGN.md choices. Scan covers initial dashboard, not open modal; modal craft reviewed from actual captures and keyboard evidence." + }, + { + "stage": "final", + "status": "scanned", + "command": "node /Users/sarthak/Desktop/fleet/saas-maker/tooling/scripts/slop-score.mjs http://127.0.0.1:8794/dashboard --json", + "report": ".fleet-local/evidence/hosted-feedback-20261004/slop-full-app-final.json", + "findingsReview": "Geist, permanent dark workbench, and four-item path group are intentional existing DESIGN.md choices. Scan covers initial dashboard, not open modal; modal craft reviewed from actual captures and keyboard evidence." + } + ] + }, + "visualReview": { + "status": "pass", + "reviewer": "Root agent direct rendered review", + "direction": "existing-design", + "report": ".fleet-local/evidence/hosted-feedback-20261004/visual-review.md", + "checks": { + "hierarchy": { + "status": "pass", + "observation": "Direct line, type, required fields, optional fields, consent and Submit retain clear sequence in actual form captures." + }, + "typography": { + "status": "pass", + "observation": "Geist workbench unchanged; dialog title, labels and focus are legible at all three widths; subdued consent noted in review." + }, + "composition": { + "status": "pass", + "observation": "Actual guest dashboard remains behind a right-side desktop dialog; compact form has no horizontal overflow." + }, + "identity": { + "status": "pass", + "observation": "Quiet black learning workbench preserved; shared navy Direct line form intentionally distinct; focused and shared routes remain uninterrupted." + }, + "interaction": { + "status": "pass", + "observation": "Title gets focus; Tab wraps Submit to Close; Escape and Close restore Feedback; one dialog after reopen; focused study and public roadmap have zero feedback triggers." + }, + "responsive": { + "status": "pass", + "observation": "Complete CI-built app captured at 390, 768 and 1440; required controls and consent visible, 44px trigger, no horizontal overflow." + } + } + }, + "screenshots": [ + { + "width": 390, + "path": ".fleet-local/evidence/hosted-feedback-20261004/full-app-after-390.png" + }, + { + "width": 768, + "path": ".fleet-local/evidence/hosted-feedback-20261004/full-app-after-768.png" + }, + { + "width": 1440, + "path": ".fleet-local/evidence/hosted-feedback-20261004/full-app-after-1440.png" + } + ], + "projectCheck": { + "command": "pnpm quality (GitHub Actions 37192953403 at e91fc369664c270dca8221bd2e69b0bc37d1d473)", + "status": "pass" + }, + "critique": { + "score": 35, + "maximum": 40, + "dimensions": { + "hierarchy": 7, + "typography": 7, + "composition": 7, + "identity": 6, + "interaction": 4, + "responsive": 4 + } + }, + "audit": { + "score": 17, + "maximum": 20, + "dimensions": { + "purpose": 4, + "accessibility": 5, + "behavior": 3, + "responsive": 3, + "performance": 2 + } + }, + "unresolved": { + "p0": 0, + "p1": 0 + }, + "detector": { + "posture": "advisory", + "findings": [] + }, + "comprehension": { + "status": "not-applicable", + "reviewer": "", + "answers": { + "product": "", + "audience": "", + "value": "", + "mechanism": "", + "proof": "", + "nextAction": "" + }, + "purposeScore": { + "product": 0, + "audience": 0, + "value": 0, + "mechanism": 0, + "proof": 0, + "nextAction": 0, + "total": 0 + }, + "mismatches": [] + } + }, + "ownerFeedback": { + "decision": "not-required", + "note": "Preserve work follows the existing direction; this is not owner acceptance of the result." + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca3c7d83..cbec99cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,8 @@ on: jobs: ci: runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout uses: actions/checkout@v6 @@ -39,3 +41,15 @@ jobs: run: pnpm quality env: VITE_GOOGLE_CLIENT_ID: ci-placeholder.apps.googleusercontent.com + + - name: Retain browser qualification build + if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v7 + with: + name: browser-build-${{ github.event.pull_request.head.sha }} + path: | + dist/** + !dist/wasm/** + if-no-files-found: error + retention-days: 1 + compression-level: 6 diff --git a/package.json b/package.json index 06a89cf9..6567517d 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,6 @@ "@excalidraw/excalidraw": "0.18.1", "@monaco-editor/react": "4.7.0", "@radix-ui/react-tooltip": "^1.2.16", - "@saas-maker/feedback": "^0.2.1", "ai": "6.0.168", "clsx": "^2.1.1", "fflate": "^0.8.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b42f3f68..4a4521b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,9 +48,6 @@ importers: '@saas-maker/app-health': specifier: https://github.com/sass-maker/app-health/releases/download/node-v0.4.0/saas-maker-app-health-0.4.0.tgz version: https://github.com/sass-maker/app-health/releases/download/node-v0.4.0/saas-maker-app-health-0.4.0.tgz - '@saas-maker/feedback': - specifier: ^0.2.1 - version: 0.2.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) ai: specifier: 6.0.168 version: 6.0.168(zod@4.4.3) @@ -2211,12 +2208,6 @@ packages: hono: optional: true - '@saas-maker/feedback@0.2.1': - resolution: {integrity: sha512-ajtrH7t/hhzxrNzX2BU0i5nfGpXNu7w4EQCFBajsuFn255O29trRCu3VNL1NoQhWwypjEEdeaK4DqoEY4zd7rw==} - peerDependencies: - react: '>=18' - react-dom: '>=18' - '@sindresorhus/is@7.2.0': resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} engines: {node: '>=18'} @@ -6309,11 +6300,6 @@ snapshots: '@saas-maker/app-health@https://github.com/sass-maker/app-health/releases/download/node-v0.4.0/saas-maker-app-health-0.4.0.tgz': {} - '@saas-maker/feedback@0.2.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': - dependencies: - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - '@sindresorhus/is@7.2.0': {} '@size-limit/file@11.2.0(size-limit@11.2.0)': diff --git a/src/components/saasmaker-feedback.test.tsx b/src/components/saasmaker-feedback.test.tsx index d0489bb4..bf8b3cc8 100644 --- a/src/components/saasmaker-feedback.test.tsx +++ b/src/components/saasmaker-feedback.test.tsx @@ -8,15 +8,10 @@ import { fetchFeedbackProjectKey, SaaSMakerFeedback } from './saasmaker-feedback const { TEST_KEY } = vi.hoisted(() => ({ TEST_KEY: 'pk_testfixture123' })); -vi.mock('@saas-maker/feedback', async () => { - const React = await import('react'); - return { - FeedbackWidget: ({ projectId }: { projectId: string }) => - React.createElement('div', { - 'data-testid': 'feedback-widget', - 'data-project-bound': String(projectId === TEST_KEY), - }), - }; +const launcher = () => ({ + mountSharedFooterFeedback: vi.fn<(node: HTMLElement, options: { apiKey: string }) => void>(), + openSharedFooterFeedback: vi.fn<(node: HTMLElement, options: { apiKey: string }) => void>(), + unmountSharedFooterFeedback: vi.fn<(node: HTMLElement) => void>(), }); describe('SaaS Maker feedback binding', () => { @@ -28,41 +23,174 @@ describe('SaaS Maker feedback binding', () => { document.body.append(container); root = createRoot(container); vi.stubGlobal('fetch', vi.fn()); + const append = document.head.append.bind(document.head); + vi.spyOn(document.head, 'append').mockImplementation((...nodes) => { + // Happy DOM emits an immediate error for disabled external scripts. + // Keep the DOM node inert so each test controls load/error timing. + for (const node of nodes) { + if (node instanceof HTMLScriptElement) node.type = 'application/x-feedback-test'; + } + append(...nodes); + }); + delete (window as Window & { SaasMakerFeedback?: unknown }).SaasMakerFeedback; }); afterEach(async () => { await act(async () => root.unmount()); container.remove(); vi.unstubAllGlobals(); + vi.restoreAllMocks(); + vi.useRealTimers(); + delete (window as Window & { SaasMakerFeedback?: unknown }).SaasMakerFeedback; }); - it('mounts the existing widget only with the catalog-bound publishable key', async () => { + it('loads on intent, mounts with the catalog key, and reopens the same root', async () => { + vi.spyOn(window, 'location', 'get').mockReturnValue({ + origin: 'https://learn.significanthobbies.com', + pathname: '/practice', + href: 'https://learn.significanthobbies.com/practice?invite=testfixture#private', + } as Location); vi.mocked(fetch).mockResolvedValue({ ok: true, json: async () => ({ api_key: TEST_KEY }), } as Response); await act(async () => root.render()); - for ( - let attempt = 0; - attempt < 5 && !container.querySelector('[data-testid="feedback-widget"]'); - attempt += 1 - ) { - await act(async () => new Promise((resolve) => setTimeout(resolve, 0))); - } + expect( + document.querySelector('script[src="https://sassmaker.com/feedback-launcher.js"]') + ).toBeNull(); + const button = container.querySelector('button')!; + await act(async () => button.click()); + expect(button.disabled).toBe(false); + expect(button.getAttribute('aria-busy')).toBe('true'); + expect(document.activeElement).toBe(button); + await act(async () => button.click()); + expect( + document.querySelectorAll('script[src="https://sassmaker.com/feedback-launcher.js"]') + ).toHaveLength(1); + const api = launcher(); + (window as Window & { SaasMakerFeedback?: unknown }).SaasMakerFeedback = api; + const script = document.querySelector( + 'script[src="https://sassmaker.com/feedback-launcher.js"]' + )!; + await act(async () => script.dispatchEvent(new Event('load'))); expect(fetch).toHaveBeenCalledWith( 'https://api.sassmaker.com/v1/capture-config/swe-interview-prep', expect.objectContaining({ headers: { accept: 'application/json' } }) ); - expect(container.querySelector('[data-project-bound="true"]')).not.toBeNull(); + expect(api.mountSharedFooterFeedback).toHaveBeenCalledWith( + expect.any(HTMLDivElement), + expect.objectContaining({ + apiKey: TEST_KEY, + pageUrl: 'https://learn.significanthobbies.com/practice', + }) + ); + const mountNode = api.mountSharedFooterFeedback.mock.calls[0][0]; + expect(mountNode.children).toHaveLength(0); + await act(async () => button.click()); + expect(api.mountSharedFooterFeedback).toHaveBeenCalledTimes(1); + expect(api.openSharedFooterFeedback).toHaveBeenCalledWith( + mountNode, + expect.objectContaining({ apiKey: TEST_KEY }) + ); + await act(async () => root.render(null)); + expect(api.unmountSharedFooterFeedback).toHaveBeenCalledWith(mountNode); + expect(mountNode.isConnected).toBe(false); + }); + + it('retries a failed module and rejects a loaded module without its API', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ api_key: TEST_KEY }), + } as Response); + await act(async () => root.render()); + const button = container.querySelector('button')!; + await act(async () => button.click()); + const first = document.querySelector( + 'script[src="https://sassmaker.com/feedback-launcher.js"]' + )!; + await act(async () => first.dispatchEvent(new Event('error'))); + expect(container.textContent).toContain('Retry feedback'); + expect(first.isConnected).toBe(false); + await act(async () => button.click()); + const second = document.querySelector( + 'script[src="https://sassmaker.com/feedback-launcher.js"]' + )!; + expect(second).not.toBe(first); + await act(async () => second.dispatchEvent(new Event('load'))); + expect(container.textContent).toContain('Retry feedback'); + await act(async () => button.click()); + const api = launcher(); + (window as Window & { SaasMakerFeedback?: unknown }).SaasMakerFeedback = api; + await act(async () => + document + .querySelector('script[src="https://sassmaker.com/feedback-launcher.js"]')! + .dispatchEvent(new Event('load')) + ); + expect(api.mountSharedFooterFeedback).toHaveBeenCalledTimes(1); + }); + + it('ignores a late script load after unmount and uses a fresh node after remount', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ api_key: TEST_KEY }), + } as Response); + await act(async () => root.render()); + await act(async () => container.querySelector('button')!.click()); + const script = document.querySelector( + 'script[src="https://sassmaker.com/feedback-launcher.js"]' + )!; + await act(async () => root.render(null)); + const api = launcher(); + (window as Window & { SaasMakerFeedback?: unknown }).SaasMakerFeedback = api; + await act(async () => script.dispatchEvent(new Event('load'))); + expect(api.mountSharedFooterFeedback).not.toHaveBeenCalled(); + await act(async () => root.render()); + await act(async () => container.querySelector('button')!.click()); + const first = api.mountSharedFooterFeedback.mock.calls[0][0]; + first.dataset.feedbackMounted = 'true'; + await act(async () => root.render(null)); + await act(async () => root.render()); + await act(async () => container.querySelector('button')!.click()); + const second = api.mountSharedFooterFeedback.mock.calls[1][0]; + expect(second).not.toBe(first); + expect(second.dataset.feedbackMounted).toBeUndefined(); + expect(api.unmountSharedFooterFeedback).toHaveBeenCalledTimes(1); + }); + + it('times out a stalled module, ignores its late callback, and permits retry', async () => { + vi.useFakeTimers(); + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ api_key: TEST_KEY }), + } as Response); + await act(async () => root.render()); + const button = container.querySelector('button')!; + await act(async () => button.click()); + const script = document.querySelector( + 'script[src="https://sassmaker.com/feedback-launcher.js"]' + )!; + const lateLoad = script.onload!; + await act(async () => vi.advanceTimersByTime(8_000)); + expect(container.textContent).toContain('Retry feedback'); + expect(script.isConnected).toBe(false); + const api = launcher(); + (window as Window & { SaasMakerFeedback?: unknown }).SaasMakerFeedback = api; + await act(async () => lateLoad.call(script, new Event('load'))); + expect(api.mountSharedFooterFeedback).not.toHaveBeenCalled(); + await act(async () => button.click()); + expect(api.mountSharedFooterFeedback).toHaveBeenCalledTimes(1); }); it.each([ ['invalid key shape', { ok: true, json: async () => ({ api_key: 'wrong-project' }) }], [ 'invented publishable suffix', - { ok: true, json: async () => ({ api_key: 'pk_testfixture123_publishable' }) }, + { + ok: true, + json: async () => ({ api_key: 'pk_testfixture123_publishable' }), + }, ], ['unavailable binding', { ok: false, json: async () => ({}) }], ])('fails closed for %s', async (_case, response) => { diff --git a/src/components/saasmaker-feedback.tsx b/src/components/saasmaker-feedback.tsx index 92f555c6..8a4f9164 100644 --- a/src/components/saasmaker-feedback.tsx +++ b/src/components/saasmaker-feedback.tsx @@ -1,21 +1,44 @@ -import '@saas-maker/feedback/dist/index.css'; - -import { lazy, Suspense, useEffect, useState } from 'react'; +import { MessageSquare } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; const API_BASE = 'https://api.sassmaker.com'; const CATALOG_ID = 'swe-interview-prep'; const PUBLISHABLE_KEY_PATTERN = /^pk_[a-z0-9]+$/; const CONFIG_TIMEOUT_MS = 8_000; -const FeedbackWidget = lazy(async () => { - const mod = await import('@saas-maker/feedback'); - return { default: mod.FeedbackWidget }; -}); +const LAUNCHER_URL = 'https://sassmaker.com/feedback-launcher.js'; +type FeedbackOptions = { apiKey: string; pageUrl: string; pageTitle: string }; +type FeedbackLauncher = { + mountSharedFooterFeedback: (node: HTMLElement, options: FeedbackOptions) => void; + openSharedFooterFeedback: (node: HTMLElement, options: FeedbackOptions) => void; + unmountSharedFooterFeedback: (node: HTMLElement) => void; +}; + +function getLauncher(): FeedbackLauncher | null { + const candidate = (window as Window & { SaasMakerFeedback?: FeedbackLauncher }).SaasMakerFeedback; + return candidate && + typeof candidate.mountSharedFooterFeedback === 'function' && + typeof candidate.openSharedFooterFeedback === 'function' && + typeof candidate.unmountSharedFooterFeedback === 'function' + ? candidate + : null; +} export function SaaSMakerFeedback() { const [projectKey, setProjectKey] = useState(null); + const [status, setStatus] = useState<'idle' | 'loading' | 'error'>('idle'); + const host = useRef(null); + const trigger = useRef(null); + const mounted = useRef<{ + node: HTMLDivElement; + api: FeedbackLauncher; + } | null>(null); + const pending = useRef(null); + const loadTimeout = useRef(null); + const isMounted = useRef(false); useEffect(() => { + isMounted.current = true; const controller = new AbortController(); let active = true; const timeout = window.setTimeout(() => controller.abort(), CONFIG_TIMEOUT_MS); @@ -27,24 +50,133 @@ export function SaaSMakerFeedback() { .finally(() => window.clearTimeout(timeout)); return () => { + isMounted.current = false; active = false; window.clearTimeout(timeout); controller.abort(); + if (loadTimeout.current !== null) window.clearTimeout(loadTimeout.current); + if (pending.current) { + pending.current.onload = null; + pending.current.onerror = null; + pending.current.remove(); + pending.current = null; + } + const widget = mounted.current; + mounted.current = null; + if (widget) { + try { + widget.api.unmountSharedFooterFeedback(widget.node); + } finally { + widget.node.remove(); + } + } }; }, []); + function open(api: FeedbackLauncher) { + if (!isMounted.current || !projectKey || !host.current) return; + // The shared dialog remembers the active element for focus restoration. + trigger.current?.focus(); + const options = { + apiKey: projectKey, + pageUrl: window.location.origin + window.location.pathname, + pageTitle: document.title, + }; + try { + if (mounted.current) { + mounted.current.api.openSharedFooterFeedback(mounted.current.node, options); + } else { + // A fresh node avoids the launcher's retained mounted dataset marker. + const node = document.createElement('div'); + host.current.append(node); + mounted.current = { node, api }; + api.mountSharedFooterFeedback(node, options); + } + setStatus('idle'); + } catch { + const widget = mounted.current; + mounted.current = null; + if (widget) { + try { + widget.api.unmountSharedFooterFeedback(widget.node); + } finally { + widget.node.remove(); + } + } + setStatus('error'); + } + } + + function handleOpen() { + trigger.current?.focus(); + if (pending.current) return; + const api = getLauncher(); + if (api) { + open(api); + return; + } + setStatus('loading'); + const script = document.createElement('script'); + script.src = LAUNCHER_URL; + script.async = true; + pending.current = script; + const finish = () => { + if (loadTimeout.current !== null) window.clearTimeout(loadTimeout.current); + loadTimeout.current = null; + script.onload = null; + script.onerror = null; + script.remove(); + pending.current = null; + }; + script.onload = () => { + if (pending.current !== script) return; + finish(); + if (!isMounted.current) return; + const loaded = getLauncher(); + if (loaded) open(loaded); + else setStatus('error'); + }; + script.onerror = () => { + if (pending.current !== script) return; + finish(); + if (isMounted.current) setStatus('error'); + }; + loadTimeout.current = window.setTimeout(() => { + finish(); + if (isMounted.current) setStatus('error'); + }, CONFIG_TIMEOUT_MS); + document.head.append(script); + } + if (!projectKey) return null; return ( - - - + <> + + + {status === 'error' && ( + + Feedback couldn’t load. Please try again. + + )} + + + {status === 'loading' + ? 'Loading feedback…' + : status === 'error' + ? 'Retry feedback' + : 'Feedback'} + + + > ); }
+ Feedback couldn’t load. Please try again. +