From 7c488e433ed72df03458ad92454f1598d0af42f9 Mon Sep 17 00:00:00 2001 From: Philippos Savvides Date: Sat, 15 Aug 2026 09:40:32 -0700 Subject: [PATCH 01/19] docs: add design spec for idstack chrome extension experiment --- .../2026-08-15-chrome-extension-design.md | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-15-chrome-extension-design.md diff --git a/docs/superpowers/specs/2026-08-15-chrome-extension-design.md b/docs/superpowers/specs/2026-08-15-chrome-extension-design.md new file mode 100644 index 0000000..7d8de38 --- /dev/null +++ b/docs/superpowers/specs/2026-08-15-chrome-extension-design.md @@ -0,0 +1,144 @@ +# idstack Chrome Extension Experiment — Design Specification + +**Date:** 2026-08-15 +**Status:** Approved for Implementation +**Topic:** Frictionless Evidence-Based Course Design Chrome Extension for Educators & Instructional Designers + +--- + +## 1. Executive Summary & Problem Statement + +### 1.1 Context +`idstack` provides evidence-based instructional design skills powered by 108 peer-reviewed studies across 11 research domains, classifying learning objectives with Bloom's taxonomy and grading recommendations with evidence tiers (T1–T5). + +While the existing Claude Code CLI plugin works well for terminal-proficient users, the vast majority of educators, faculty members, and instructional designers (IDs) work exclusively in graphical environments—primarily Learning Management Systems (Canvas LMS, Blackboard, Brightspace, Moodle), document processors (Google Docs, Microsoft Word), and web browsers. + +### 1.2 The Opportunity +Building institutional LMS integrations (LTI 1.3, Canvas Developer Keys) introduces severe bureaucratic friction (IT security reviews, procurement cycles, administrator access requirements). A **Manifest V3 Chrome Extension** eliminates all institutional gatekeeping: +- An educator installs the extension directly from the Chrome Web Store in seconds. +- It operates directly on their active browser tab using their existing authenticated session. +- It provides a native Chrome Side Panel co-pilot that audits course material and produces actionable, evidence-backed improvements in seconds. + +--- + +## 2. Core User Experience & Workflows + +### 2.1 The 1-Click Audit Workflow +``` +[Educator in Canvas / Google Doc] + │ + ▼ (Clicks idstack extension icon) +[Chrome Side Panel Opens] + │ + ▼ (Automatic platform detection: "Canvas Assignment Detected") +[User clicks "Audit with Evidence"] + │ + ▼ (2-3s fast analysis via Gemini 3.7 Flash) +[Results Displayed in Side Panel] + ├── Bloom's Taxonomy Level & Alignment Summary + ├── Finding Cards with [T1]–[T5] Evidence Badges + ├── 1-Click Improved Revision (Objective, Rubric, Prompt) + └── "Copy to Clipboard" / "View Full Stakeholder Report" +``` + +### 2.2 Micro-Interactions & States +1. **Ready State:** Shows detected context (e.g. `[Canvas LMS] Intro to Biology - Module 2 Assignment`) with a clear call-to-action button: **"Audit Page with Evidence"**. +2. **Auditing State:** Displays an elegant skeleton loader with dynamic progress text (*"Extracting learning outcomes..."* → *"Checking Bloom's taxonomy..."* → *"Synthesizing research evidence..."*). +3. **Results State:** + - **Summary Banner:** High-level Bloom's tier (e.g., *Remember / Understand vs. Analyze / Evaluate*) and alignment rating. + - **Finding Cards:** Categorized by severity (Critical / Warning / Suggestion) with mono-tagged citation chips (e.g. `[Assessment-8] [T1]`). + - **Actionable Rewrite Box:** Formatted revised rubric criteria or rewritten measurable learning objectives with a **"📋 Copy to Clipboard"** button. + - **Feedback Mechanism:** Instant 1-click **"Helpful? 👍 / 👎"** toggle to collect experiment signal. +4. **Settings / Privacy State:** Clean drawer allowing users to view privacy commitments (zero student PII stored) and optionally enter a custom Gemini / Claude API key for BYOK mode. + +--- + +## 3. System Architecture & Components + +``` +idstack/ +├── extension/ +│ ├── manifest.json # Chrome MV3 configuration +│ ├── icons/ # Branded icon assets (16x16, 48x48, 128x128) +│ ├── sidepanel/ +│ │ ├── index.html # Side panel DOM structure +│ │ ├── sidepanel.css # Styled with idstack DESIGN.md design system +│ │ └── sidepanel.js # UI logic, view transitions, clipboard operations +│ ├── content/ +│ │ ├── extractor.js # DOM content extractors (Canvas, Google Docs, generic web) +│ │ └── extractor.css # Injected visual indicators (if applicable) +│ ├── background/ +│ │ └── service-worker.js # Background worker, message broker & API client +│ └── shared/ +│ ├── evidence-base.js # Curated T1-T5 evidence citations from evidence/references.md +│ ├── prompts.js # Structured audit prompts and JSON output schemas +│ └── storage.js # Chrome sync/local storage helpers +``` + +### 3.1 Content Script (`content/extractor.js`) +The content script is injected on demand or on target domains to extract course content without requiring Canvas API tokens: +- **Canvas LMS (`*.instructure.com` / `canvas.*.edu`):** + - Targets `#assignment_show`, `.description.user_content`, `#rubrics`, `.module-item-title`, `#syllabusContainer`. + - Captures title, learning outcomes, assignment instructions, and existing rubric grids. +- **Google Docs (`docs.google.com/document/*`):** + - Extracts text from the active document container or user selection. +- **Generic Web Syllabi / Pages:** + - Extracts semantic `
`, `
`, or high-density text containers while pruning navigational noise and footers. + +### 3.2 Background Worker (`background/service-worker.js`) +- Receives audit requests from the Side Panel. +- Formats prompt with extracted DOM text + idstack research criteria. +- Dispatches request to the configured LLM endpoint (Gemini 3.7 Flash default proxy or local BYOK endpoint). +- Validates and parses the returned JSON schema and forwards results to the Side Panel. + +--- + +## 4. Visual Design & Aesthetics (`DESIGN.md` Adherence) + +The extension UI strictly complies with the existing `DESIGN.md` specification: + +- **Typography:** + - Body & headings: `Source Serif 4` for publication-grade, authoritative feel. + - UI labels & buttons: `Public Sans` (clean official record aesthetic). + - Citations, badges, & code: `JetBrains Mono` for `[Alignment-14] [T1]` evidence marks. +- **Color Tokens:** + - Background: `#faf8f3` (pristine ivory). + - Raised surfaces / cards: `#ffffff`. + - Primary text: `#1a1815` (warm near-black). + - Borders / Dividers: `#e6e0d2` hairline rules. + - Evidence Tiers: + - `T1` (Meta-analysis): Prussian blue `#1d4e89` + - `T2` (Controlled trials): Teal `#007791` + - `T3` (Observational): Olive `#588157` + - `T4` (Case studies): Ochre `#c97a22` + - `T5` (Expert guidance): Slate `#6c757d` +- **Zero Cliché Tropes:** + - No purple gradients, no glowing neon borders, no emoji spam, no generic SaaS templates. + +--- + +## 5. Security, Privacy & FERPA Compliance + +1. **No Student Data Collection:** The extension operates solely on instructional design artifacts (syllabi, assignment prompts, rubrics, learning outcomes). +2. **Zero Permanent Storage on External Servers:** Prompts sent to the AI backend are processed ephemerally with zero data retention for training. +3. **Client-Side Manifest Memory:** Course manifests and audit history are persisted locally in `chrome.storage.local`. + +--- + +## 6. Success Metrics & Validation Gate + +For this experiment to be considered successful before expanding into larger features: +1. **Frictionless Completion:** A first-time user can install the extension and receive an audit in < 30 seconds. +2. **Utility Signal:** > 40% of audit runs result in the educator clicking **"Copy to Clipboard"** for the revised rubric/objective. +3. **Qualitative Rating:** > 80% positive ("👍") feedback on finding relevance and accuracy. + +--- + +## 7. Implementation Plan Sequence + +1. **Phase 1: Project Scaffolding & Manifest:** Create `extension/manifest.json`, icon assets, and baseline extension setup. +2. **Phase 2: Content Extraction Engine:** Implement `content/extractor.js` for Canvas LMS and general web pages. +3. **Phase 3: Side Panel UI & Design System:** Implement `sidepanel/index.html`, `sidepanel.css` (using `DESIGN.md` tokens), and interactive state rendering. +4. **Phase 4: LLM Audit Engine & Prompts:** Implement `background/service-worker.js` with structured Gemini 3.7 Flash prompting and JSON schema enforcement. +5. **Phase 5: Copy Actions & Stakeholder Report Generator:** Wire 1-click clipboard revision copying and standalone HTML report generation. +6. **Phase 6: Testing & Verification:** Verify on real Canvas pages and web syllabi. From 82bc131e16d892ef50f6a019d50006dba72bfefc Mon Sep 17 00:00:00 2001 From: Philippos Savvides Date: Sat, 15 Aug 2026 09:41:08 -0700 Subject: [PATCH 02/19] docs: add implementation plan for idstack chrome extension experiment --- .../2026-08-15-chrome-extension-experiment.md | 997 ++++++++++++++++++ 1 file changed, 997 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-15-chrome-extension-experiment.md diff --git a/docs/superpowers/plans/2026-08-15-chrome-extension-experiment.md b/docs/superpowers/plans/2026-08-15-chrome-extension-experiment.md new file mode 100644 index 0000000..0be01cf --- /dev/null +++ b/docs/superpowers/plans/2026-08-15-chrome-extension-experiment.md @@ -0,0 +1,997 @@ +# idstack Chrome Extension Experiment Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a frictionless Manifest V3 Chrome Extension that brings idstack's evidence-based course design audits into Canvas LMS, Google Docs, and web browsers via Chrome's native Side Panel. + +**Architecture:** A Chrome Manifest V3 extension featuring a DOM content extractor for Canvas and web pages, a Side Panel user interface matching the `DESIGN.md` publication aesthetic, and a background service worker dispatching structured audit requests to Gemini 3.7 Flash with T1–T5 evidence citations. + +**Tech Stack:** Chrome Extensions API (Manifest V3, Side Panel API, Content Scripts, Service Worker), Vanilla JavaScript (ES Modules), Vanilla CSS (matching `DESIGN.md` tokens), Node.js test runner for unit tests. + +## Global Constraints + +- **Design System:** Strictly follow `DESIGN.md` (Source Serif 4, Public Sans, JetBrains Mono, Ivory `#faf8f3`, Raised `#ffffff`, Text `#1a1815`, hairline borders `#e6e0d2`, T1–T5 palette). +- **Zero Framework Overhead:** Pure HTML/CSS/JavaScript without bundlers or build steps so the extension can be loaded directly unpacked in developer mode. +- **Privacy & FERPA:** Do not store student or course data permanently on third-party servers. +- **T1–T5 Research Grounding:** All audit suggestions must tie back to the 108 studies and 11 research domains in `evidence/references.md`. + +--- + +### Task 1: Scaffolding, Manifest V3 & Icon Assets + +**Files:** +- Create: `extension/manifest.json` +- Create: `extension/icons/icon-16.png` +- Create: `extension/icons/icon-48.png` +- Create: `extension/icons/icon-128.png` +- Create: `extension/shared/storage.js` +- Test: `test/test-manifest.js` + +**Interfaces:** +- Produces: `getSettings()`, `saveSettings(settings)`, `getAuditHistory()`, `saveAuditResult(result)` in `extension/shared/storage.js`. + +- [ ] **Step 1: Write test for manifest validity and storage helper** + +Create `test/test-manifest.js`: +```javascript +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); + +// Test manifest.json +const manifestPath = path.join(__dirname, '../extension/manifest.json'); +assert.ok(fs.existsSync(manifestPath), 'manifest.json must exist'); +const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + +assert.strictEqual(manifest.manifest_version, 3); +assert.strictEqual(manifest.name, 'idstack — Evidence-Based Course Design'); +assert.ok(manifest.permissions.includes('sidePanel')); +assert.ok(manifest.permissions.includes('storage')); +assert.ok(manifest.permissions.includes('activeTab')); + +console.log('✅ Task 1 manifest checks passed.'); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node test/test-manifest.js` +Expected: FAIL with "manifest.json must exist" + +- [ ] **Step 3: Implement `manifest.json`, icon generation script, and `storage.js`** + +Create `extension/manifest.json`: +```json +{ + "manifest_version": 3, + "name": "idstack — Evidence-Based Course Design", + "version": "1.0.0", + "description": "Evidence-based instructional design co-pilot for Canvas LMS, Google Docs, and course web pages.", + "icons": { + "16": "icons/icon-16.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + }, + "action": { + "default_title": "Open idstack Course Co-Pilot" + }, + "side_panel": { + "default_path": "sidepanel/index.html" + }, + "background": { + "service_worker": "background/service-worker.js", + "type": "module" + }, + "content_scripts": [ + { + "matches": [ + "*://*.instructure.com/*", + "*://canvas.*.edu/*", + "*://docs.google.com/document/*", + "" + ], + "js": ["content/extractor.js"], + "run_at": "document_idle" + } + ], + "permissions": [ + "sidePanel", + "storage", + "activeTab", + "scripting" + ] +} +``` + +Create `extension/shared/storage.js`: +```javascript +/** + * idstack storage helper for Chrome sync and local storage. + */ +export async function getSettings() { + return new Promise((resolve) => { + chrome.storage.sync.get(['apiKey', 'apiEndpoint', 'autoAudit'], (result) => { + resolve({ + apiKey: result.apiKey || '', + apiEndpoint: result.apiEndpoint || 'https://api.idstack.org/v1/audit', + autoAudit: result.autoAudit ?? false + }); + }); + }); +} + +export async function saveSettings(settings) { + return new Promise((resolve) => { + chrome.storage.sync.set(settings, () => resolve(true)); + }); +} + +export async function getAuditHistory() { + return new Promise((resolve) => { + chrome.storage.local.get(['auditHistory'], (result) => { + resolve(result.auditHistory || []); + }); + }); +} + +export async function saveAuditResult(entry) { + return new Promise((resolve) => { + chrome.storage.local.get(['auditHistory'], (result) => { + const history = result.auditHistory || []; + history.unshift({ + ...entry, + timestamp: new Date().toISOString() + }); + // Keep last 20 audits + chrome.storage.local.set({ auditHistory: history.slice(0, 20) }, () => resolve(true)); + }); + }); +} +``` + +Generate SVG/PNG icons in `extension/icons/` using a small canvas generator script or standalone PNG buffer. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node test/test-manifest.js` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add extension/manifest.json extension/icons/ extension/shared/storage.js test/test-manifest.js +git commit -m "feat(extension): scaffold manifest v3 and storage helpers" +``` + +--- + +### Task 2: Evidence Base & Prompt Generation Engine + +**Files:** +- Create: `extension/shared/evidence-base.js` +- Create: `extension/shared/prompts.js` +- Test: `test/test-prompts.js` + +**Interfaces:** +- Consumes: None +- Produces: `EVIDENCE_DOMAINS`, `TIER_METADATA`, `buildAuditPrompt(contextPayload)` + +- [ ] **Step 1: Write test for prompt generator and evidence metadata** + +Create `test/test-prompts.js`: +```javascript +const assert = require('assert'); +const { EVIDENCE_DOMAINS, TIER_METADATA, buildAuditPrompt } = require('../extension/shared/prompts.cjs'); + +assert.ok(EVIDENCE_DOMAINS.length >= 10, 'Should include all core idstack research domains'); +assert.ok(TIER_METADATA.T1, 'Tier 1 metadata must exist'); +assert.strictEqual(TIER_METADATA.T1.label, 'Meta-analysis'); + +const prompt = buildAuditPrompt({ + title: 'Biology 101 - Cell Division Assignment', + pageType: 'assignment', + content: 'Students will list the phases of mitosis and take a 5-question multiple choice quiz.' +}); + +assert.ok(prompt.includes('Biology 101'), 'Prompt should include content title'); +assert.ok(prompt.includes("Bloom's"), "Prompt should require Bloom's classification"); +assert.ok(prompt.includes('JSON'), 'Prompt should enforce JSON format'); + +console.log('✅ Task 2 prompt engine tests passed.'); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node test/test-prompts.js` +Expected: FAIL with module not found + +- [ ] **Step 3: Implement `evidence-base.js` and `prompts.js`** + +Create `extension/shared/evidence-base.js`: +```javascript +export const TIER_METADATA = { + T1: { label: 'Meta-analysis', description: 'Systematic reviews / meta-analyses with large effect sizes', color: '#1d4e89' }, + T2: { label: 'Controlled trial', description: 'Peer-reviewed empirical randomized or quasi-experimental studies', color: '#007791' }, + T3: { label: 'Observational', description: 'Correlational, cohort, or longitudinal learning studies', color: '#588157' }, + T4: { label: 'Case study', description: 'Single-institution or discipline-specific qualitative studies', color: '#c97a22' }, + T5: { label: 'Expert guidance', description: 'Established instructional design frameworks (QM, OLC, Bloom)', color: '#6c757d' } +}; + +export const EVIDENCE_DOMAINS = [ + { code: 'Assessment', name: 'Assessment & Feedback', keyStudies: ['Hattie & Timperley (2007) [T1]', 'Black & Wiliam (1998) [T1]'] }, + { code: 'Alignment', name: 'Constructive Alignment', keyStudies: ['Biggs (1996) [T1]', 'Anderson & Krathwohl (2001) [T5]'] }, + { code: 'Cognitive', name: 'Cognitive Load & Multimedia', keyStudies: ['Sweller (1988) [T1]', 'Mayer (2009) [T1]'] }, + { code: 'Accessibility', name: 'Universal Design for Learning & A11y', keyStudies: ['CAST UDL Guidelines (2018) [T5]', 'WCAG 2.1 AA [T5]'] }, + { code: 'Active', name: 'Active Learning & Desirable Difficulties', keyStudies: ['Freeman et al. (2014) [T1]', 'Bjork & Bjork (2011) [T1]'] } +]; +``` + +Create `extension/shared/prompts.js`: +```javascript +import { EVIDENCE_DOMAINS, TIER_METADATA } from './evidence-base.js'; + +export function buildAuditPrompt({ title, pageType, content }) { + return `You are idstack, an evidence-based instructional design co-pilot. +Your mission is to audit the provided course page/document and give rigorous, research-backed recommendations. + +Target Document Title: "${title || 'Untitled Course Page'}" +Detected Document Type: ${pageType || 'Course Content'} + +Document Content: +""" +${content.slice(0, 10000)} +""" + +Please audit this material against peer-reviewed instructional design evidence: +1. Classify learning objectives or implied cognitive depth using Bloom's Revised Taxonomy (Remember, Understand, Apply, Analyze, Evaluate, Create). +2. Check Constructive Alignment: Do activities and assessments match the stated or necessary cognitive depth? +3. Flag Cognitive Load, Elaborated Feedback gaps, and Accessibility considerations. +4. Rate each recommendation with an evidence tier [T1] to [T5]. +5. Provide a ready-to-use, improved version (rewritten rubric, upgraded learning outcome verbs, or enhanced prompt). + +You MUST respond strictly with valid JSON conforming to this schema: +{ + "summary": { + "bloomsLevel": "Remember | Understand | Apply | Analyze | Evaluate | Create", + "alignmentScore": "Strong | Moderate | Weak", + "keyTakeaway": "1-2 sentence executive summary of findings" + }, + "findings": [ + { + "severity": "critical | warning | suggestion", + "tier": "T1 | T2 | T3 | T4 | T5", + "citation": "[Domain-Code] Short Citation", + "observation": "What is present in the current material", + "evidence": "What peer-reviewed research indicates", + "recommendation": "Specific actionable suggestion" + } + ], + "improvedDraft": { + "title": "Improved Rubric / Learning Objective / Assignment Prompt", + "content": "Full markdown text ready for the instructor to copy-paste into Canvas" + } +}`; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node test/test-prompts.js` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add extension/shared/evidence-base.js extension/shared/prompts.js test/test-prompts.js +git commit -m "feat(extension): add evidence base and structured prompt templates" +``` + +--- + +### Task 3: Content Extractor for Canvas LMS, Google Docs & Generic Web + +**Files:** +- Create: `extension/content/extractor.js` +- Test: `test/test-extractor.js` + +**Interfaces:** +- Consumes: Page DOM +- Produces: `extractPageContent() -> { title, pageType, url, content, wordCount }` + +- [ ] **Step 1: Write test for DOM extraction logic using JSDOM** + +Create `test/test-extractor.js`: +```javascript +const assert = require('assert'); +const { JSDOM } = require('jsdom'); +const { extractContentFromDOM } = require('../extension/content/extractor-core.cjs'); + +// Test Canvas Assignment fixture +const canvasHTML = ` + + Module 3: Enzymes Assignment + +
+

Enzymes Lab Analysis

+
+

Read chapter 4 and answer the 5 review questions.

+
+
+ + +`; +const dom = new JSDOM(canvasHTML, { url: 'https://canvas.instructure.com/courses/101/assignments/202' }); +const extracted = extractContentFromDOM(dom.window.document, dom.window.location.href); + +assert.strictEqual(extracted.pageType, 'Canvas Assignment'); +assert.strictEqual(extracted.title, 'Enzymes Lab Analysis'); +assert.ok(extracted.content.includes('Read chapter 4')); + +console.log('✅ Task 3 extractor tests passed.'); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node test/test-extractor.js` +Expected: FAIL + +- [ ] **Step 3: Implement `extractor.js` and common extractor module** + +Create `extension/content/extractor.js`: +```javascript +/** + * Injected content script to extract course content from Canvas, Google Docs, and web pages. + */ +function detectPageType(url, document) { + if (url.includes('instructure.com') || url.includes('/courses/')) { + if (document.querySelector('#assignment_show')) return 'Canvas Assignment'; + if (document.querySelector('#syllabusContainer') || url.includes('/assignments/syllabus')) return 'Canvas Syllabus'; + if (document.querySelector('#modules') || url.includes('/modules')) return 'Canvas Modules'; + if (document.querySelector('#rubrics')) return 'Canvas Rubric'; + return 'Canvas LMS Page'; + } + if (url.includes('docs.google.com/document')) return 'Google Doc Syllabus'; + return 'Web Syllabus / Course Page'; +} + +function extractPageContent() { + const url = window.location.href; + const pageType = detectPageType(url, document); + let title = document.title || 'Course Document'; + let content = ''; + + if (pageType.startsWith('Canvas')) { + const heading = document.querySelector('#assignment_show .title, .page-title, h1'); + if (heading) title = heading.innerText.trim(); + + const mainBody = document.querySelector('#assignment_show .description.user_content, .show-content.user_content, #syllabusContainer, .module-item-title'); + content = mainBody ? mainBody.innerText.trim() : document.body.innerText.trim(); + } else if (pageType === 'Google Doc Syllabus') { + const kixApp = document.querySelector('.kix-appview-editor'); + content = kixApp ? kixApp.innerText.trim() : document.body.innerText.trim(); + } else { + // General web page extraction + const article = document.querySelector('main, article, [role="main"]'); + content = article ? article.innerText.trim() : document.body.innerText.trim(); + } + + return { + url, + title, + pageType, + content: content.slice(0, 15000), + wordCount: content.split(/\s+/).filter(Boolean).length + }; +} + +// Listen for messages from Side Panel +chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + if (request.action === 'EXTRACT_CONTENT') { + const data = extractPageContent(); + sendResponse(data); + } + return true; +}); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node test/test-extractor.js` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add extension/content/ test/test-extractor.js +git commit -m "feat(extension): implement DOM extractor for Canvas and web syllabi" +``` + +--- + +### Task 4: Background Service Worker & Gemini API Engine + +**Files:** +- Create: `extension/background/service-worker.js` +- Test: `test/test-service-worker.js` + +**Interfaces:** +- Consumes: `buildAuditPrompt()`, `getSettings()`, `saveAuditResult()` +- Produces: Chrome message listener for `RUN_AUDIT` + +- [ ] **Step 1: Write test for API request dispatch & JSON sanitization** + +Create `test/test-service-worker.js`: +```javascript +const assert = require('assert'); +const { parseAuditResponse } = require('../extension/background/parser-helper.cjs'); + +const rawGeminiResponse = "```json\n{\n \"summary\": {\n \"bloomsLevel\": \"Remember\",\n \"alignmentScore\": \"Moderate\",\n \"keyTakeaway\": \"Quiz focuses only on memorization.\"\n },\n \"findings\": [],\n \"improvedDraft\": {\n \"title\": \"Analysis Prompt\",\n \"content\": \"Compare and contrast\"\n }\n}\n```"; + +const parsed = parseAuditResponse(rawGeminiResponse); +assert.strictEqual(parsed.summary.bloomsLevel, 'Remember'); +assert.strictEqual(parsed.improvedDraft.title, 'Analysis Prompt'); + +console.log('✅ Task 4 response parser tests passed.'); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node test/test-service-worker.js` +Expected: FAIL + +- [ ] **Step 3: Implement `service-worker.js` and parsing logic** + +Create `extension/background/service-worker.js`: +```javascript +import { buildAuditPrompt } from '../shared/prompts.js'; +import { getSettings, saveAuditResult } from '../shared/storage.js'; + +// Setup side panel behavior on action click +chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch((err) => console.error(err)); + +function cleanJsonResponse(rawText) { + let cleaned = rawText.trim(); + if (cleaned.startsWith('```json')) cleaned = cleaned.replace(/^```json/, ''); + if (cleaned.startsWith('```')) cleaned = cleaned.replace(/^```/, ''); + if (cleaned.endsWith('```')) cleaned = cleaned.replace(/```$/, ''); + return JSON.parse(cleaned.trim()); +} + +async function callGeminiApi(apiKey, prompt) { + const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`; + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + contents: [{ parts: [{ text: prompt }] }], + generationConfig: { + responseMimeType: 'application/json', + temperature: 0.2 + } + }) + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`AI API error (${response.status}): ${errorText}`); + } + + const data = await response.json(); + const rawText = data.candidates?.[0]?.content?.parts?.[0]?.text; + if (!rawText) throw new Error('Empty response from AI model.'); + + return cleanJsonResponse(rawText); +} + +chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + if (request.action === 'RUN_AUDIT') { + (async () => { + try { + const settings = await getSettings(); + const prompt = buildAuditPrompt(request.payload); + + // Use user's key if provided, or default endpoint + let auditResult; + if (settings.apiKey) { + auditResult = await callGeminiApi(settings.apiKey, prompt); + } else { + // Fallback demo mock or proxy endpoint + auditResult = await callGeminiApi('YOUR_DEFAULT_API_KEY_OR_PROXY', prompt); + } + + await saveAuditResult({ + url: request.payload.url, + title: request.payload.title, + pageType: request.payload.pageType, + result: auditResult + }); + + sendResponse({ success: true, data: auditResult }); + } catch (err) { + sendResponse({ success: false, error: err.message }); + } + })(); + return true; // async reply + } +}); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node test/test-service-worker.js` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add extension/background/ test/test-service-worker.js +git commit -m "feat(extension): implement background worker and API request engine" +``` + +--- + +### Task 5: Side Panel UI Structure & Publication Styling (`DESIGN.md`) + +**Files:** +- Create: `extension/sidepanel/index.html` +- Create: `extension/sidepanel/sidepanel.css` +- Test: `test/test-sidepanel-dom.js` + +**Interfaces:** +- Produces: HTML structure with `#ready-state`, `#loading-state`, `#results-state`, `#settings-drawer`. + +- [ ] **Step 1: Write test asserting key UI elements and DESIGN.md color tokens** + +Create `test/test-sidepanel-dom.js`: +```javascript +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); + +const html = fs.readFileSync(path.join(__dirname, '../extension/sidepanel/index.html'), 'utf8'); +const css = fs.readFileSync(path.join(__dirname, '../extension/sidepanel/sidepanel.css'), 'utf8'); + +assert.ok(html.includes('id="audit-btn"'), 'Audit button must exist in HTML'); +assert.ok(html.includes('id="results-container"'), 'Results container must exist'); +assert.ok(css.includes('--bg: #faf8f3'), 'CSS must include ivory background token from DESIGN.md'); +assert.ok(css.includes('Source Serif 4'), 'CSS must use Source Serif 4 typography'); +assert.ok(css.includes('JetBrains Mono'), 'CSS must use JetBrains Mono for citations'); + +console.log('✅ Task 5 side panel DOM and CSS token tests passed.'); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node test/test-sidepanel-dom.js` +Expected: FAIL + +- [ ] **Step 3: Implement `index.html` and `sidepanel.css`** + +Create `extension/sidepanel/index.html`: +```html + + + + + + idstack — Course Co-Pilot + + + + + + +
+
+ + v1.0 +
+ +
+ +
+ +
+
+
+ Detecting page... +

Loading page title...

+
+

Audit constructive alignment, Bloom's classification, and cognitive load with peer-reviewed evidence.

+ +
+
+ + +
+
+
+

Analyzing course structure...

+
+
+ + +
+
+
+ + +
+
+

Settings & Privacy

+ +
+
+
+ + +

Leave blank to use the standard free demo tier.

+
+ +
+
+

Privacy & FERPA Commitment

+

idstack processes only curriculum and assignment text. No student PII is ever collected, retained, or used for model training.

+
+
+
+
+ + + + +``` + +Create `extension/sidepanel/sidepanel.css`: +```css +:root { + --bg: #faf8f3; + --raised: #ffffff; + --ink: #1a1815; + --ink-soft: #3a352e; + --ink-muted: #6b6358; + --rule: #e6e0d2; + --rule-strong: #d4cdb9; + + --tier-t1: #1d4e89; + --tier-t2: #007791; + --tier-t3: #588157; + --tier-t4: #c97a22; + --tier-t5: #6c757d; + + --severity-critical: #a62626; + --severity-warning: #c25e00; + --severity-suggestion: #2b7a4b; + + --font-display: 'Source Serif 4', Georgia, serif; + --font-ui: 'Public Sans', -apple-system, BlinkMacSystemFont, sans-serif; + --font-mono: 'JetBrains Mono', monospace; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } +body { + background-color: var(--bg); + color: var(--ink); + font-family: var(--font-display); + font-size: 15px; + line-height: 1.6; +} + +.app-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid var(--rule); + background: var(--raised); +} + +.brand .logo { + font-family: var(--font-display); + font-weight: 600; + font-size: 18px; + letter-spacing: -0.02em; +} + +.badge-version { + font-family: var(--font-mono); + font-size: 11px; + color: var(--ink-muted); + margin-left: 6px; +} + +.state-panel { display: none; padding: 16px; } +.state-panel.active { display: block; } + +.context-card, .finding-card, .improved-box { + background: var(--raised); + border: 1px solid var(--rule); + border-radius: 4px; + padding: 16px; + margin-bottom: 16px; +} + +.chip { + font-family: var(--font-mono); + font-size: 11px; + background: #ede8dc; + padding: 2px 6px; + border-radius: 3px; + color: var(--ink-soft); +} + +.primary-btn { + width: 100%; + padding: 10px 16px; + background: var(--ink); + color: #ffffff; + font-family: var(--font-ui); + font-weight: 600; + border: none; + border-radius: 4px; + cursor: pointer; + margin-top: 12px; +} + +.primary-btn:hover { background: #333; } + +.tier-badge { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 600; + padding: 2px 6px; + border-radius: 3px; + color: #ffffff; +} + +.tier-t1 { background: var(--tier-t1); } +.tier-t2 { background: var(--tier-t2); } +.tier-t3 { background: var(--tier-t3); } +.tier-t4 { background: var(--tier-t4); } +.tier-t5 { background: var(--tier-t5); } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node test/test-sidepanel-dom.js` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add extension/sidepanel/index.html extension/sidepanel/sidepanel.css test/test-sidepanel-dom.js +git commit -m "feat(extension): create sidepanel UI template and publication design system" +``` + +--- + +### Task 6: Side Panel Controller, Rendering & 1-Click Clipboard Actions + +**Files:** +- Create: `extension/sidepanel/sidepanel.js` +- Test: `test/test-sidepanel-logic.js` + +**Interfaces:** +- Consumes: Extracted DOM payload, `RUN_AUDIT` message, `storage.js` +- Produces: Rendered summary cards, finding lists with evidence tags, 1-click clipboard copy, and feedback handlers. + +- [ ] **Step 1: Write test for result HTML builder** + +Create `test/test-sidepanel-logic.js`: +```javascript +const assert = require('assert'); +const { renderAuditHTML } = require('../extension/sidepanel/renderer-helper.cjs'); + +const mockData = { + summary: { + bloomsLevel: 'Analyze', + alignmentScore: 'Strong', + keyTakeaway: 'Great constructive alignment between rubric and lab analysis.' + }, + findings: [ + { + severity: 'suggestion', + tier: 'T1', + citation: '[Assessment-8] Elaborated Feedback', + observation: 'Rubric uses generic grading bands.', + evidence: 'Elaborated criteria increase metacognitive monitoring.', + recommendation: 'Add milestone descriptions for each level.' + } + ], + improvedDraft: { + title: 'Rewritten Rubric Matrix', + content: '| Criterion | Exemplary | Developing |\n|---|---|---|' + } +}; + +const rendered = renderAuditHTML(mockData); +assert.ok(rendered.includes('Analyze'), "Must render Bloom's level"); +assert.ok(rendered.includes('[Assessment-8]'), 'Must render citation'); +assert.ok(rendered.includes('copy-improved-btn'), 'Must include 1-click copy button'); + +console.log('✅ Task 6 renderer tests passed.'); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node test/test-sidepanel-logic.js` +Expected: FAIL + +- [ ] **Step 3: Implement `sidepanel.js`** + +Create `extension/sidepanel/sidepanel.js`: +```javascript +import { getSettings, saveSettings } from '../shared/storage.js'; + +let activePayload = null; + +async function refreshActiveTab() { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tab || !tab.id) return; + + try { + const response = await chrome.tabs.sendMessage(tab.id, { action: 'EXTRACT_CONTENT' }); + if (response) { + activePayload = response; + document.getElementById('page-type-tag').textContent = response.pageType; + document.getElementById('page-title').textContent = response.title; + } + } catch (err) { + document.getElementById('page-type-tag').textContent = 'Web Page'; + document.getElementById('page-title').textContent = tab.title || 'Current Tab'; + activePayload = { + url: tab.url, + title: tab.title, + pageType: 'Web Page', + content: '' + }; + } +} + +function showState(stateName) { + document.querySelectorAll('.state-panel').forEach(el => el.classList.remove('active')); + document.getElementById(`${stateName}-state`).classList.add('active'); +} + +function renderResults(data) { + const container = document.getElementById('results-container'); + + const findingsHTML = data.findings.map(f => ` +
+
+ ${f.tier} + ${f.citation} +
+

Observation: ${f.observation}

+

Evidence: ${f.evidence}

+

Recommendation: ${f.recommendation}

+
+ `).join(''); + + container.innerHTML = ` +
+
+ Bloom's: ${data.summary.bloomsLevel} + Alignment: ${data.summary.alignmentScore} +
+

${data.summary.keyTakeaway}

+
+ +

Evidence-Based Findings (${data.findings.length})

+
${findingsHTML}
+ +
+
+

${data.improvedDraft.title}

+ +
+
${data.improvedDraft.content}
+
+ + + `; + + document.getElementById('copy-improved-btn').addEventListener('click', () => { + navigator.clipboard.writeText(data.improvedDraft.content); + const btn = document.getElementById('copy-improved-btn'); + btn.textContent = '✓ Copied!'; + setTimeout(() => { btn.textContent = '📋 Copy to Clipboard'; }, 2000); + }); + + document.getElementById('re-audit-btn').addEventListener('click', () => { + showState('ready'); + refreshActiveTab(); + }); + + document.querySelectorAll('.feedback-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + e.target.parentElement.innerHTML = 'Thank you for your feedback!'; + }); + }); + + showState('results'); +} + +document.getElementById('audit-btn').addEventListener('click', async () => { + if (!activePayload) await refreshActiveTab(); + showState('loading'); + + chrome.runtime.sendMessage({ action: 'RUN_AUDIT', payload: activePayload }, (response) => { + if (response && response.success) { + renderResults(response.data); + } else { + alert(`Audit failed: ${response?.error || 'Unknown error'}`); + showState('ready'); + } + }); +}); + +// Init +refreshActiveTab(); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node test/test-sidepanel-logic.js` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add extension/sidepanel/sidepanel.js test/test-sidepanel-logic.js +git commit -m "feat(extension): add sidepanel controller and 1-click clipboard integration" +``` + +--- + +### Task 7: Verification, Smoke Testing & Integration into Root Suite + +**Files:** +- Create: `test/test-extension.sh` +- Modify: `test/smoke-test.sh` +- Modify: `README.md` + +- [ ] **Step 1: Create automated extension test runner script** + +Create `test/test-extension.sh`: +```bash +#!/usr/bin/env bash +set -euo pipefail + +echo "==> Running idstack Chrome Extension test suite..." +node test/test-manifest.js +node test/test-prompts.js +node test/test-extractor.js +node test/test-service-worker.js +node test/test-sidepanel-dom.js +node test/test-sidepanel-logic.js + +echo "==> All Chrome Extension tests passed!" +``` + +- [ ] **Step 2: Make `test/test-extension.sh` executable and run it** + +Run: `chmod +x test/test-extension.sh && ./test/test-extension.sh` +Expected: PASS with 6/6 test suites passing. + +- [ ] **Step 3: Integrate into `test/smoke-test.sh` and update `README.md` with Chrome Extension section** + +Update `README.md` to include quick-start instructions for loading the extension unpacked into Chrome. + +- [ ] **Step 4: Run full project smoke test** + +Run: `./test/smoke-test.sh` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add test/test-extension.sh test/smoke-test.sh README.md +git commit -m "test(extension): integrate chrome extension test suite into smoke tests" +``` From 206cdefb489e50855cc4b6bff62373dc6371994e Mon Sep 17 00:00:00 2001 From: Philippos Savvides Date: Sat, 15 Aug 2026 09:43:10 -0700 Subject: [PATCH 03/19] feat(extension): scaffold manifest v3 and storage helpers --- extension/icons/generate-icons.js | 90 ++++++++++++++++++++++++++++++ extension/icons/icon-128.png | Bin 0 -> 358 bytes extension/icons/icon-16.png | Bin 0 -> 96 bytes extension/icons/icon-48.png | Bin 0 -> 150 bytes extension/manifest.json | 39 +++++++++++++ extension/shared/storage.js | 42 ++++++++++++++ test/test-manifest.js | 30 ++++++++++ 7 files changed, 201 insertions(+) create mode 100644 extension/icons/generate-icons.js create mode 100644 extension/icons/icon-128.png create mode 100644 extension/icons/icon-16.png create mode 100644 extension/icons/icon-48.png create mode 100644 extension/manifest.json create mode 100644 extension/shared/storage.js create mode 100644 test/test-manifest.js diff --git a/extension/icons/generate-icons.js b/extension/icons/generate-icons.js new file mode 100644 index 0000000..3be9b73 --- /dev/null +++ b/extension/icons/generate-icons.js @@ -0,0 +1,90 @@ +const fs = require('fs'); +const path = require('path'); +const zlib = require('zlib'); + +function crc32(buf) { + let crc = 0xffffffff; + for (let i = 0; i < buf.length; i++) { + crc ^= buf[i]; + for (let j = 0; j < 8; j++) { + crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0); + } + } + return (crc ^ 0xffffffff) >>> 0; +} + +function makeChunk(type, data) { + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length, 0); + const typeBuf = Buffer.from(type, 'ascii'); + const crcBuf = Buffer.alloc(4); + const body = Buffer.concat([typeBuf, data]); + crcBuf.writeUInt32BE(crc32(body), 0); + return Buffer.concat([len, body, crcBuf]); +} + +function createPng(size) { + const width = size; + const height = size; + + const rowSize = 1 + width * 4; + const rawData = Buffer.alloc(height * rowSize); + + for (let y = 0; y < height; y++) { + const rowOffset = y * rowSize; + rawData[rowOffset] = 0; // Filter: None + for (let x = 0; x < width; x++) { + const pxOffset = rowOffset + 1 + x * 4; + const relX = x / width; + const relY = y / height; + + const inBox = relX >= 0.12 && relX <= 0.88 && relY >= 0.12 && relY <= 0.88; + const isStackLine1 = relY >= 0.26 && relY <= 0.38 && relX >= 0.24 && relX <= 0.76; + const isStackLine2 = relY >= 0.44 && relY <= 0.56 && relX >= 0.24 && relX <= 0.76; + const isStackLine3 = relY >= 0.62 && relY <= 0.74 && relX >= 0.24 && relX <= 0.76; + + if (isStackLine1 || isStackLine2 || isStackLine3) { + rawData[pxOffset] = 255; + rawData[pxOffset + 1] = 255; + rawData[pxOffset + 2] = 255; + rawData[pxOffset + 3] = 255; + } else if (inBox) { + rawData[pxOffset] = 79; + rawData[pxOffset + 1] = 70; + rawData[pxOffset + 2] = 229; + rawData[pxOffset + 3] = 255; + } else { + rawData[pxOffset] = 0; + rawData[pxOffset + 1] = 0; + rawData[pxOffset + 2] = 0; + rawData[pxOffset + 3] = 0; + } + } + } + + const compressed = zlib.deflateSync(rawData); + + const header = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + + const ihdrData = Buffer.alloc(13); + ihdrData.writeUInt32BE(width, 0); + ihdrData.writeUInt32BE(height, 4); + ihdrData[8] = 8; + ihdrData[9] = 6; + ihdrData[10] = 0; + ihdrData[11] = 0; + ihdrData[12] = 0; + + const ihdrChunk = makeChunk('IHDR', ihdrData); + const idatChunk = makeChunk('IDAT', compressed); + const iendChunk = makeChunk('IEND', Buffer.alloc(0)); + + return Buffer.concat([header, ihdrChunk, idatChunk, iendChunk]); +} + +const iconsDir = path.join(__dirname); +[16, 48, 128].forEach(size => { + const png = createPng(size); + fs.writeFileSync(path.join(iconsDir, `icon-${size}.png`), png); +}); +console.log('Icons generated successfully.'); diff --git a/extension/icons/icon-128.png b/extension/icons/icon-128.png new file mode 100644 index 0000000000000000000000000000000000000000..f27c98c394cae5be116ec6c292cd5bede12025aa GIT binary patch literal 358 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrVAS<=aSW-L^Y+q4&ISVp2FK-_ zOaC*@aX#Hps?fH-`Tm1`W}sRSc<|Wjy?eH?gAMZ$qKG}Y>ts@M5= rFwC0F_()P=9#Mp@EX4l|4fa)xU-oWIzWFNu97xF1)z4*}Q$iB}&a`%v literal 0 HcmV?d00001 diff --git a/extension/icons/icon-16.png b/extension/icons/icon-16.png new file mode 100644 index 0000000000000000000000000000000000000000..bf195d9df4f16dd20efb0bd400b2977d21c2c2a1 GIT binary patch literal 96 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`>Ygr+Ar*6y6C_Ly+<*JBKIo%@ tX@i7AG&9>hZszw*jo%L)kaW;vU=UY)`r}qB|7M^T22WQ%mvv4FO#qQ39Jl}g literal 0 HcmV?d00001 diff --git a/extension/icons/icon-48.png b/extension/icons/icon-48.png new file mode 100644 index 0000000000000000000000000000000000000000..c654073ee2aeabb255f47e85d6c844edfd39b9df GIT binary patch literal 150 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpUtSWg$nkcv5PuN>rUFc4rq7&2Y| zuegKk1(vSI2HHCtD)00vdPbL>{e3f+;U(h|_6hgY8$=m_`egpNF" + ], + "js": ["content/extractor.js"], + "run_at": "document_idle" + } + ], + "permissions": [ + "sidePanel", + "storage", + "activeTab", + "scripting" + ] +} diff --git a/extension/shared/storage.js b/extension/shared/storage.js new file mode 100644 index 0000000..c8bceff --- /dev/null +++ b/extension/shared/storage.js @@ -0,0 +1,42 @@ +/** + * idstack storage helper for Chrome sync and local storage. + */ +export async function getSettings() { + return new Promise((resolve) => { + chrome.storage.sync.get(['apiKey', 'apiEndpoint', 'autoAudit'], (result) => { + resolve({ + apiKey: result.apiKey || '', + apiEndpoint: result.apiEndpoint || 'https://api.idstack.org/v1/audit', + autoAudit: result.autoAudit ?? false + }); + }); + }); +} + +export async function saveSettings(settings) { + return new Promise((resolve) => { + chrome.storage.sync.set(settings, () => resolve(true)); + }); +} + +export async function getAuditHistory() { + return new Promise((resolve) => { + chrome.storage.local.get(['auditHistory'], (result) => { + resolve(result.auditHistory || []); + }); + }); +} + +export async function saveAuditResult(entry) { + return new Promise((resolve) => { + chrome.storage.local.get(['auditHistory'], (result) => { + const history = result.auditHistory || []; + history.unshift({ + ...entry, + timestamp: new Date().toISOString() + }); + // Keep last 20 audits + chrome.storage.local.set({ auditHistory: history.slice(0, 20) }, () => resolve(true)); + }); + }); +} diff --git a/test/test-manifest.js b/test/test-manifest.js new file mode 100644 index 0000000..9eec342 --- /dev/null +++ b/test/test-manifest.js @@ -0,0 +1,30 @@ +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); + +// Test manifest.json +const manifestPath = path.join(__dirname, '../extension/manifest.json'); +assert.ok(fs.existsSync(manifestPath), 'manifest.json must exist'); +const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + +assert.strictEqual(manifest.manifest_version, 3); +assert.strictEqual(manifest.name, 'idstack — Evidence-Based Course Design'); +assert.ok(manifest.permissions.includes('sidePanel')); +assert.ok(manifest.permissions.includes('storage')); +assert.ok(manifest.permissions.includes('activeTab')); + +// Storage helper check +const storagePath = path.join(__dirname, '../extension/shared/storage.js'); +assert.ok(fs.existsSync(storagePath), 'storage.js must exist'); +const storageContent = fs.readFileSync(storagePath, 'utf8'); +assert.ok(storageContent.includes('export async function getSettings()'), 'getSettings export required'); +assert.ok(storageContent.includes('export async function saveSettings('), 'saveSettings export required'); +assert.ok(storageContent.includes('export async function getAuditHistory()'), 'getAuditHistory export required'); +assert.ok(storageContent.includes('export async function saveAuditResult('), 'saveAuditResult export required'); + +// Icons check +assert.ok(fs.existsSync(path.join(__dirname, '../extension/icons/icon-16.png')), 'icon-16.png must exist'); +assert.ok(fs.existsSync(path.join(__dirname, '../extension/icons/icon-48.png')), 'icon-48.png must exist'); +assert.ok(fs.existsSync(path.join(__dirname, '../extension/icons/icon-128.png')), 'icon-128.png must exist'); + +console.log('✅ Task 1 manifest checks passed.'); From 9e35ced04cc62660a5127d5aa9e7d37c630ab0d4 Mon Sep 17 00:00:00 2001 From: Philippos Savvides Date: Sat, 15 Aug 2026 09:44:50 -0700 Subject: [PATCH 04/19] feat(extension): add evidence base and structured prompt templates --- extension/shared/evidence-base.cjs | 26 ++++++++++++++++ extension/shared/evidence-base.js | 21 +++++++++++++ extension/shared/prompts.cjs | 50 ++++++++++++++++++++++++++++++ extension/shared/prompts.js | 46 +++++++++++++++++++++++++++ test/test-prompts.js | 18 +++++++++++ 5 files changed, 161 insertions(+) create mode 100644 extension/shared/evidence-base.cjs create mode 100644 extension/shared/evidence-base.js create mode 100644 extension/shared/prompts.cjs create mode 100644 extension/shared/prompts.js create mode 100644 test/test-prompts.js diff --git a/extension/shared/evidence-base.cjs b/extension/shared/evidence-base.cjs new file mode 100644 index 0000000..715754a --- /dev/null +++ b/extension/shared/evidence-base.cjs @@ -0,0 +1,26 @@ +const TIER_METADATA = { + T1: { label: 'Meta-analysis', description: 'Systematic reviews / meta-analyses with large effect sizes', color: '#1d4e89' }, + T2: { label: 'Controlled trial', description: 'Peer-reviewed empirical randomized or quasi-experimental studies', color: '#007791' }, + T3: { label: 'Observational', description: 'Correlational, cohort, or longitudinal learning studies', color: '#588157' }, + T4: { label: 'Case study', description: 'Single-institution or discipline-specific qualitative studies', color: '#c97a22' }, + T5: { label: 'Expert guidance', description: 'Established instructional design frameworks (QM, OLC, Bloom)', color: '#6c757d' } +}; + +const EVIDENCE_DOMAINS = [ + { code: 'Models', name: 'Instructional Design Models & Frameworks', keyStudies: ['Abuhassna et al. (2024) [T3]', 'Kalonde et al. (2025) [T3]', 'Crompton et al. (2023) [T3]'] }, + { code: 'Alignment', name: 'Constructive Alignment & Learning Objectives', keyStudies: ['Biggs (1996) [T5]', 'Anderson & Krathwohl (2001) [T5]', 'Agarwal (2019) [T1]'] }, + { code: 'Needs', name: 'Needs Analysis', keyStudies: ['Markaki et al. (2021) [T3]', 'Garavan et al. (2019) [T3]', 'Alsalamah & Callinan (2021) [T3]'] }, + { code: 'Cognitive', name: 'Cognitive Load Theory & Sequencing', keyStudies: ['Sweller (1994) [T5]', 'Costley et al. (2023) [T1]', 'Paas & van Merrienboer (2020) [T5]', 'Chen et al. (2018) [T1]'] }, + { code: 'Assessment', name: 'Formative Assessment & Feedback', keyStudies: ['Wisniewski et al. (2020) [T1]', 'Hattie & Timperley (2007) [T1]', 'Black & Wiliam (1998) [T1]', 'Double et al. (2019) [T1]'] }, + { code: 'Multimedia', name: 'Multimedia Learning Principles', keyStudies: ['Mayer (2024) [T5]', 'Moreno & Mayer (1999) [T1]', 'Noetel et al. (2021) [T1]'] }, + { code: 'Learner', name: 'Learner Analysis & Differentiation', keyStudies: ['Deunk et al. (2018) [T1]', 'Puzio et al. (2020) [T1]', 'Liou et al. (2023) [T2]'] }, + { code: 'Evaluation', name: 'Evaluation Models', keyStudies: ['Kirkpatrick / Alsalamah & Callinan (2021) [T3]', 'Frye & Hemmer (2012) [T5]', 'Allen et al. (2021) [T3]'] }, + { code: 'Prototype', name: 'Rapid Prototyping & Design-Based Research', keyStudies: ['Tripp & Bichelmeyer (1990) [T5]', 'Shakeel et al. (2022) [T2]', 'Design-Based Research Collective (2003) [T5]'] }, + { code: 'Online', name: 'Online Course Quality Frameworks', keyStudies: ['Quality Matters / Zimmerman et al. (2020) [T4]', 'Castro & Tumibay (2019) [T1]', 'Swan et al. (2012) [T2]'] }, + { code: 'Accessibility', name: 'Universal Design for Learning & A11y', keyStudies: ['CAST UDL Guidelines (2024) [T5]', 'WCAG 2.1/2.2 AA [T5]', 'Puzio et al. (2020) [T1]'] } +]; + +module.exports = { + TIER_METADATA, + EVIDENCE_DOMAINS +}; diff --git a/extension/shared/evidence-base.js b/extension/shared/evidence-base.js new file mode 100644 index 0000000..dd5d195 --- /dev/null +++ b/extension/shared/evidence-base.js @@ -0,0 +1,21 @@ +export const TIER_METADATA = { + T1: { label: 'Meta-analysis', description: 'Systematic reviews / meta-analyses with large effect sizes', color: '#1d4e89' }, + T2: { label: 'Controlled trial', description: 'Peer-reviewed empirical randomized or quasi-experimental studies', color: '#007791' }, + T3: { label: 'Observational', description: 'Correlational, cohort, or longitudinal learning studies', color: '#588157' }, + T4: { label: 'Case study', description: 'Single-institution or discipline-specific qualitative studies', color: '#c97a22' }, + T5: { label: 'Expert guidance', description: 'Established instructional design frameworks (QM, OLC, Bloom)', color: '#6c757d' } +}; + +export const EVIDENCE_DOMAINS = [ + { code: 'Models', name: 'Instructional Design Models & Frameworks', keyStudies: ['Abuhassna et al. (2024) [T3]', 'Kalonde et al. (2025) [T3]', 'Crompton et al. (2023) [T3]'] }, + { code: 'Alignment', name: 'Constructive Alignment & Learning Objectives', keyStudies: ['Biggs (1996) [T5]', 'Anderson & Krathwohl (2001) [T5]', 'Agarwal (2019) [T1]'] }, + { code: 'Needs', name: 'Needs Analysis', keyStudies: ['Markaki et al. (2021) [T3]', 'Garavan et al. (2019) [T3]', 'Alsalamah & Callinan (2021) [T3]'] }, + { code: 'Cognitive', name: 'Cognitive Load Theory & Sequencing', keyStudies: ['Sweller (1994) [T5]', 'Costley et al. (2023) [T1]', 'Paas & van Merrienboer (2020) [T5]', 'Chen et al. (2018) [T1]'] }, + { code: 'Assessment', name: 'Formative Assessment & Feedback', keyStudies: ['Wisniewski et al. (2020) [T1]', 'Hattie & Timperley (2007) [T1]', 'Black & Wiliam (1998) [T1]', 'Double et al. (2019) [T1]'] }, + { code: 'Multimedia', name: 'Multimedia Learning Principles', keyStudies: ['Mayer (2024) [T5]', 'Moreno & Mayer (1999) [T1]', 'Noetel et al. (2021) [T1]'] }, + { code: 'Learner', name: 'Learner Analysis & Differentiation', keyStudies: ['Deunk et al. (2018) [T1]', 'Puzio et al. (2020) [T1]', 'Liou et al. (2023) [T2]'] }, + { code: 'Evaluation', name: 'Evaluation Models', keyStudies: ['Kirkpatrick / Alsalamah & Callinan (2021) [T3]', 'Frye & Hemmer (2012) [T5]', 'Allen et al. (2021) [T3]'] }, + { code: 'Prototype', name: 'Rapid Prototyping & Design-Based Research', keyStudies: ['Tripp & Bichelmeyer (1990) [T5]', 'Shakeel et al. (2022) [T2]', 'Design-Based Research Collective (2003) [T5]'] }, + { code: 'Online', name: 'Online Course Quality Frameworks', keyStudies: ['Quality Matters / Zimmerman et al. (2020) [T4]', 'Castro & Tumibay (2019) [T1]', 'Swan et al. (2012) [T2]'] }, + { code: 'Accessibility', name: 'Universal Design for Learning & A11y', keyStudies: ['CAST UDL Guidelines (2024) [T5]', 'WCAG 2.1/2.2 AA [T5]', 'Puzio et al. (2020) [T1]'] } +]; diff --git a/extension/shared/prompts.cjs b/extension/shared/prompts.cjs new file mode 100644 index 0000000..2967144 --- /dev/null +++ b/extension/shared/prompts.cjs @@ -0,0 +1,50 @@ +const { EVIDENCE_DOMAINS, TIER_METADATA } = require('./evidence-base.cjs'); + +function buildAuditPrompt({ title, pageType, content = '' }) { + return `You are idstack, an evidence-based instructional design co-pilot. +Your mission is to audit the provided course page/document and give rigorous, research-backed recommendations. + +Target Document Title: "${title || 'Untitled Course Page'}" +Detected Document Type: ${pageType || 'Course Content'} + +Document Content: +""" +${(content || '').slice(0, 10000)} +""" + +Please audit this material against peer-reviewed instructional design evidence: +1. Classify learning objectives or implied cognitive depth using Bloom's Revised Taxonomy (Remember, Understand, Apply, Analyze, Evaluate, Create). +2. Check Constructive Alignment: Do activities and assessments match the stated or necessary cognitive depth? +3. Flag Cognitive Load, Elaborated Feedback gaps, and Accessibility considerations. +4. Rate each recommendation with an evidence tier [T1] to [T5]. +5. Provide a ready-to-use, improved version (rewritten rubric, upgraded learning outcome verbs, or enhanced prompt). + +You MUST respond strictly with valid JSON conforming to this schema: +{ + "summary": { + "bloomsLevel": "Remember | Understand | Apply | Analyze | Evaluate | Create", + "alignmentScore": "Strong | Moderate | Weak", + "keyTakeaway": "1-2 sentence executive summary of findings" + }, + "findings": [ + { + "severity": "critical | warning | suggestion", + "tier": "T1 | T2 | T3 | T4 | T5", + "citation": "[Domain-Code] Short Citation", + "observation": "What is present in the current material", + "evidence": "What peer-reviewed research indicates", + "recommendation": "Specific actionable suggestion" + } + ], + "improvedDraft": { + "title": "Improved Rubric / Learning Objective / Assignment Prompt", + "content": "Full markdown text ready for the instructor to copy-paste into Canvas" + } +}`; +} + +module.exports = { + EVIDENCE_DOMAINS, + TIER_METADATA, + buildAuditPrompt +}; diff --git a/extension/shared/prompts.js b/extension/shared/prompts.js new file mode 100644 index 0000000..943ef85 --- /dev/null +++ b/extension/shared/prompts.js @@ -0,0 +1,46 @@ +import { EVIDENCE_DOMAINS, TIER_METADATA } from './evidence-base.js'; + +export function buildAuditPrompt({ title, pageType, content = '' }) { + return `You are idstack, an evidence-based instructional design co-pilot. +Your mission is to audit the provided course page/document and give rigorous, research-backed recommendations. + +Target Document Title: "${title || 'Untitled Course Page'}" +Detected Document Type: ${pageType || 'Course Content'} + +Document Content: +""" +${(content || '').slice(0, 10000)} +""" + +Please audit this material against peer-reviewed instructional design evidence: +1. Classify learning objectives or implied cognitive depth using Bloom's Revised Taxonomy (Remember, Understand, Apply, Analyze, Evaluate, Create). +2. Check Constructive Alignment: Do activities and assessments match the stated or necessary cognitive depth? +3. Flag Cognitive Load, Elaborated Feedback gaps, and Accessibility considerations. +4. Rate each recommendation with an evidence tier [T1] to [T5]. +5. Provide a ready-to-use, improved version (rewritten rubric, upgraded learning outcome verbs, or enhanced prompt). + +You MUST respond strictly with valid JSON conforming to this schema: +{ + "summary": { + "bloomsLevel": "Remember | Understand | Apply | Analyze | Evaluate | Create", + "alignmentScore": "Strong | Moderate | Weak", + "keyTakeaway": "1-2 sentence executive summary of findings" + }, + "findings": [ + { + "severity": "critical | warning | suggestion", + "tier": "T1 | T2 | T3 | T4 | T5", + "citation": "[Domain-Code] Short Citation", + "observation": "What is present in the current material", + "evidence": "What peer-reviewed research indicates", + "recommendation": "Specific actionable suggestion" + } + ], + "improvedDraft": { + "title": "Improved Rubric / Learning Objective / Assignment Prompt", + "content": "Full markdown text ready for the instructor to copy-paste into Canvas" + } +}`; +} + +export { EVIDENCE_DOMAINS, TIER_METADATA }; diff --git a/test/test-prompts.js b/test/test-prompts.js new file mode 100644 index 0000000..02f74d6 --- /dev/null +++ b/test/test-prompts.js @@ -0,0 +1,18 @@ +const assert = require('assert'); +const { EVIDENCE_DOMAINS, TIER_METADATA, buildAuditPrompt } = require('../extension/shared/prompts.cjs'); + +assert.ok(EVIDENCE_DOMAINS.length >= 10, 'Should include all core idstack research domains'); +assert.ok(TIER_METADATA.T1, 'Tier 1 metadata must exist'); +assert.strictEqual(TIER_METADATA.T1.label, 'Meta-analysis'); + +const prompt = buildAuditPrompt({ + title: 'Biology 101 - Cell Division Assignment', + pageType: 'assignment', + content: 'Students will list the phases of mitosis and take a 5-question multiple choice quiz.' +}); + +assert.ok(prompt.includes('Biology 101'), 'Prompt should include content title'); +assert.ok(prompt.includes("Bloom's"), "Prompt should require Bloom's classification"); +assert.ok(prompt.includes('JSON'), 'Prompt should enforce JSON format'); + +console.log('✅ Task 2 prompt engine tests passed.'); From 3767b8ffd5f7280c4dab2dc83d168250507ab3dd Mon Sep 17 00:00:00 2001 From: Philippos Savvides Date: Sat, 15 Aug 2026 09:47:23 -0700 Subject: [PATCH 05/19] feat(extension): implement DOM extractor for Canvas and web syllabi --- extension/content/extractor-core.cjs | 7 + extension/content/extractor-core.js | 45 ++++ extension/content/extractor.js | 67 ++++++ test/test-extractor.js | 318 +++++++++++++++++++++++++++ 4 files changed, 437 insertions(+) create mode 100644 extension/content/extractor-core.cjs create mode 100644 extension/content/extractor-core.js create mode 100644 extension/content/extractor.js create mode 100644 test/test-extractor.js diff --git a/extension/content/extractor-core.cjs b/extension/content/extractor-core.cjs new file mode 100644 index 0000000..02b88b9 --- /dev/null +++ b/extension/content/extractor-core.cjs @@ -0,0 +1,7 @@ +const { detectPageType, extractContentFromDOM, extractPageContent } = require('./extractor.js'); + +module.exports = { + detectPageType, + extractContentFromDOM, + extractPageContent +}; diff --git a/extension/content/extractor-core.js b/extension/content/extractor-core.js new file mode 100644 index 0000000..f4d18d6 --- /dev/null +++ b/extension/content/extractor-core.js @@ -0,0 +1,45 @@ +export function detectPageType(url, document) { + if (url.includes('instructure.com') || url.includes('/courses/')) { + if (document.querySelector('#assignment_show')) return 'Canvas Assignment'; + if (document.querySelector('#syllabusContainer') || url.includes('/assignments/syllabus')) return 'Canvas Syllabus'; + if (document.querySelector('#modules') || url.includes('/modules')) return 'Canvas Modules'; + if (document.querySelector('#rubrics')) return 'Canvas Rubric'; + return 'Canvas LMS Page'; + } + if (url.includes('docs.google.com/document')) return 'Google Doc Syllabus'; + return 'Web Syllabus / Course Page'; +} + +export function extractContentFromDOM(document, url = (typeof window !== 'undefined' && window.location ? window.location.href : '')) { + const pageType = detectPageType(url, document); + let title = (document && document.title) || 'Course Document'; + let content = ''; + + if (pageType.startsWith('Canvas')) { + const heading = document.querySelector('#assignment_show .title, .page-title, h1'); + if (heading) title = (heading.innerText || heading.textContent || '').trim(); + + const mainBody = document.querySelector('#assignment_show .description.user_content, .show-content.user_content, #syllabusContainer, .module-item-title'); + content = mainBody ? (mainBody.innerText || mainBody.textContent || '').trim() : (document.body ? (document.body.innerText || document.body.textContent || '').trim() : ''); + } else if (pageType === 'Google Doc Syllabus') { + const kixApp = document.querySelector('.kix-appview-editor'); + content = kixApp ? (kixApp.innerText || kixApp.textContent || '').trim() : (document.body ? (document.body.innerText || document.body.textContent || '').trim() : ''); + } else { + // General web page extraction + const article = document.querySelector('main, article, [role="main"]'); + content = article ? (article.innerText || article.textContent || '').trim() : (document.body ? (document.body.innerText || document.body.textContent || '').trim() : ''); + } + + return { + url, + title, + pageType, + content: content.slice(0, 15000), + wordCount: content.split(/\s+/).filter(Boolean).length + }; +} + +export function extractPageContent() { + const url = typeof window !== 'undefined' && window.location ? window.location.href : ''; + return extractContentFromDOM(document, url); +} diff --git a/extension/content/extractor.js b/extension/content/extractor.js new file mode 100644 index 0000000..f5e25ae --- /dev/null +++ b/extension/content/extractor.js @@ -0,0 +1,67 @@ +/** + * Injected content script to extract course content from Canvas, Google Docs, and web pages. + */ +function detectPageType(url, document) { + if (url.includes('instructure.com') || url.includes('/courses/')) { + if (document.querySelector('#assignment_show')) return 'Canvas Assignment'; + if (document.querySelector('#syllabusContainer') || url.includes('/assignments/syllabus')) return 'Canvas Syllabus'; + if (document.querySelector('#modules') || url.includes('/modules')) return 'Canvas Modules'; + if (document.querySelector('#rubrics')) return 'Canvas Rubric'; + return 'Canvas LMS Page'; + } + if (url.includes('docs.google.com/document')) return 'Google Doc Syllabus'; + return 'Web Syllabus / Course Page'; +} + +function extractContentFromDOM(document, url = (typeof window !== 'undefined' && window.location ? window.location.href : '')) { + const pageType = detectPageType(url, document); + let title = (document && document.title) || 'Course Document'; + let content = ''; + + if (pageType.startsWith('Canvas')) { + const heading = document.querySelector('#assignment_show .title, .page-title, h1'); + if (heading) title = (heading.innerText || heading.textContent || '').trim(); + + const mainBody = document.querySelector('#assignment_show .description.user_content, .show-content.user_content, #syllabusContainer, .module-item-title'); + content = mainBody ? (mainBody.innerText || mainBody.textContent || '').trim() : (document.body ? (document.body.innerText || document.body.textContent || '').trim() : ''); + } else if (pageType === 'Google Doc Syllabus') { + const kixApp = document.querySelector('.kix-appview-editor'); + content = kixApp ? (kixApp.innerText || kixApp.textContent || '').trim() : (document.body ? (document.body.innerText || document.body.textContent || '').trim() : ''); + } else { + // General web page extraction + const article = document.querySelector('main, article, [role="main"]'); + content = article ? (article.innerText || article.textContent || '').trim() : (document.body ? (document.body.innerText || document.body.textContent || '').trim() : ''); + } + + return { + url, + title, + pageType, + content: content.slice(0, 15000), + wordCount: content.split(/\s+/).filter(Boolean).length + }; +} + +function extractPageContent() { + const url = typeof window !== 'undefined' && window.location ? window.location.href : ''; + return extractContentFromDOM(document, url); +} + +// Listen for messages from Side Panel +if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.onMessage) { + chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + if (request.action === 'EXTRACT_CONTENT') { + const data = extractPageContent(); + sendResponse(data); + } + return true; + }); +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { + detectPageType, + extractContentFromDOM, + extractPageContent + }; +} diff --git a/test/test-extractor.js b/test/test-extractor.js new file mode 100644 index 0000000..2c28324 --- /dev/null +++ b/test/test-extractor.js @@ -0,0 +1,318 @@ +const assert = require('assert'); + +// Support JSDOM if available, or fallback to built-in DOM mock for standalone node environments +let JSDOM; +try { + JSDOM = require('jsdom').JSDOM; +} catch { + class MockElement { + constructor(tagName = 'div', attrs = {}, text = '') { + this.tagName = tagName.toLowerCase(); + this.attrs = attrs; + this.children = []; + this._text = text; + } + get innerText() { + if (this.children.length > 0) { + return this.children.map(c => typeof c === 'string' ? c : c.innerText).join(' ').trim(); + } + return this._text.trim(); + } + get textContent() { + return this.innerText; + } + querySelector(selector) { + const selectors = selector.split(',').map(s => s.trim()); + for (const sel of selectors) { + const found = this._querySingle(sel); + if (found) return found; + } + return null; + } + _querySingle(selector) { + const parts = selector.split(/\s+/); + let current = [this]; + for (const part of parts) { + const next = []; + for (const el of current) { + next.push(...el._findDescendants(part)); + } + current = next; + if (current.length === 0) return null; + } + return current[0] || null; + } + _findDescendants(part) { + const results = []; + const check = (el) => { + if (el !== this) { + let match = true; + if (part.startsWith('#')) { + if (el.attrs.id !== part.slice(1)) match = false; + } else if (part.startsWith('.')) { + const requiredClasses = part.split('.').filter(Boolean); + const elClasses = (el.attrs.class || '').split(/\s+/).filter(Boolean); + if (!requiredClasses.every(cls => elClasses.includes(cls))) match = false; + } else if (part.startsWith('[')) { + const m = part.match(/\[([a-zA-Z0-9_-]+)(?:="([^"]*)")?\]/); + if (m) { + const [, attrName, attrVal] = m; + if (attrVal !== undefined) { + if (el.attrs[attrName] !== attrVal) match = false; + } else { + if (!(attrName in el.attrs)) match = false; + } + } + } else { + if (el.tagName !== part.toLowerCase()) match = false; + } + if (match) results.push(el); + } + for (const child of el.children) { + if (typeof child !== 'string') check(child); + } + }; + check(this); + return results; + } + } + + function parseHtmlToTree(html) { + const root = new MockElement('root'); + const stack = [root]; + const tagRegex = /<(\/)?([a-zA-Z0-9]+)([^>]*)>|([^<]+)/g; + let match; + + while ((match = tagRegex.exec(html)) !== null) { + const [full, isClose, tagName, attrStr, text] = match; + if (text) { + const trimmed = text.trim(); + if (trimmed) { + const current = stack[stack.length - 1]; + current.children.push(trimmed); + } + } else if (tagName) { + if (isClose) { + if (stack.length > 1 && stack[stack.length - 1].tagName === tagName.toLowerCase()) { + stack.pop(); + } + } else { + const attrs = {}; + if (attrStr) { + const attrRegex = /([a-zA-Z0-9_-]+)(?:=["']([^"']*)["'])?/g; + let attrMatch; + while ((attrMatch = attrRegex.exec(attrStr)) !== null) { + attrs[attrMatch[1]] = attrMatch[2] || ''; + } + } + const el = new MockElement(tagName, attrs); + stack[stack.length - 1].children.push(el); + if (!['meta', 'link', 'img', 'br', 'hr', 'input'].includes(tagName.toLowerCase())) { + stack.push(el); + } + } + } + } + return root; + } + + class SimpleJSDOM { + constructor(html, { url = 'http://localhost' } = {}) { + const tree = parseHtmlToTree(html); + const titleEl = tree.querySelector('title'); + const titleText = titleEl ? titleEl.innerText : ''; + const bodyEl = tree.querySelector('body') || tree; + + this.window = { + location: { href: url }, + document: { + title: titleText, + body: bodyEl, + querySelector: (sel) => tree.querySelector(sel) + } + }; + } + } + JSDOM = SimpleJSDOM; +} + +const { detectPageType, extractContentFromDOM, extractPageContent } = require('../extension/content/extractor-core.cjs'); + +// Test 1: Canvas Assignment fixture +const canvasAssignmentHTML = ` + + Module 3: Enzymes Assignment + +
+

Enzymes Lab Analysis

+
+

Read chapter 4 and answer the 5 review questions.

+
+
+ + +`; +const domAssignment = new JSDOM(canvasAssignmentHTML, { url: 'https://canvas.instructure.com/courses/101/assignments/202' }); +const extractedAssignment = extractContentFromDOM(domAssignment.window.document, domAssignment.window.location.href); + +assert.strictEqual(extractedAssignment.pageType, 'Canvas Assignment'); +assert.strictEqual(extractedAssignment.title, 'Enzymes Lab Analysis'); +assert.ok(extractedAssignment.content.includes('Read chapter 4')); +assert.strictEqual(extractedAssignment.wordCount, 9); + +// Test 2: Canvas Syllabus fixture +const canvasSyllabusHTML = ` + + Course Syllabus - CS 101 + +

CS 101 Syllabus

+
+

This course covers algorithms and data structures.

+
+ + +`; +const domSyllabus = new JSDOM(canvasSyllabusHTML, { url: 'https://canvas.instructure.com/courses/101/assignments/syllabus' }); +const extractedSyllabus = extractContentFromDOM(domSyllabus.window.document, domSyllabus.window.location.href); + +assert.strictEqual(extractedSyllabus.pageType, 'Canvas Syllabus'); +assert.strictEqual(extractedSyllabus.title, 'CS 101 Syllabus'); +assert.ok(extractedSyllabus.content.includes('algorithms and data structures')); + +// Test 3: Canvas Modules fixture +const canvasModulesHTML = ` + + Course Modules - BIO 200 + +

BIO 200 Modules

+
+ Week 1: Cellular Respiration +
+ + +`; +const domModules = new JSDOM(canvasModulesHTML, { url: 'https://canvas.instructure.com/courses/101/modules' }); +const extractedModules = extractContentFromDOM(domModules.window.document, domModules.window.location.href); + +assert.strictEqual(extractedModules.pageType, 'Canvas Modules'); +assert.strictEqual(extractedModules.title, 'BIO 200 Modules'); +assert.ok(extractedModules.content.includes('Week 1: Cellular Respiration')); + +// Test 4: Canvas Rubrics fixture +const canvasRubricsHTML = ` + + Course Rubrics + +

Grading Criteria

+
+

Criteria 1: Clarity of argument (10 pts)

+
+ + +`; +const domRubrics = new JSDOM(canvasRubricsHTML, { url: 'https://canvas.instructure.com/courses/101/rubrics' }); +const extractedRubrics = extractContentFromDOM(domRubrics.window.document, domRubrics.window.location.href); + +assert.strictEqual(extractedRubrics.pageType, 'Canvas Rubric'); +assert.strictEqual(extractedRubrics.title, 'Grading Criteria'); + +// Test 5: Canvas Generic LMS Page +const canvasGenericHTML = ` + + Course Overview + +

Welcome to PHY 101

+
+

General introduction to physics principles.

+
+ + +`; +const domGeneric = new JSDOM(canvasGenericHTML, { url: 'https://canvas.instructure.com/courses/101/pages/overview' }); +const extractedGeneric = extractContentFromDOM(domGeneric.window.document, domGeneric.window.location.href); + +assert.strictEqual(extractedGeneric.pageType, 'Canvas LMS Page'); +assert.strictEqual(extractedGeneric.title, 'Welcome to PHY 101'); +assert.ok(extractedGeneric.content.includes('physics principles')); + +// Test 6: Google Docs fixture +const gdocHTML = ` + + Instructional Design Principles - Google Docs + +
+

Course Outline: 1. Assessment Design 2. Cognitive Load Management

+
+ + +`; +const domGdoc = new JSDOM(gdocHTML, { url: 'https://docs.google.com/document/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit' }); +const extractedGdoc = extractContentFromDOM(domGdoc.window.document, domGdoc.window.location.href); + +assert.strictEqual(extractedGdoc.pageType, 'Google Doc Syllabus'); +assert.ok(extractedGdoc.content.includes('Assessment Design')); + +// Test 7: Generic Web Page fixture +const webHTML = ` + + Open Courseware Syllabus + +
+

Introduction to Machine Learning

+

Course schedule and grading policy.

+
+ + +`; +const domWeb = new JSDOM(webHTML, { url: 'https://ocw.mit.edu/syllabus/intro-ml' }); +const extractedWeb = extractContentFromDOM(domWeb.window.document, domWeb.window.location.href); + +assert.strictEqual(extractedWeb.pageType, 'Web Syllabus / Course Page'); +assert.strictEqual(extractedWeb.title, 'Open Courseware Syllabus'); +assert.ok(extractedWeb.content.includes('Machine Learning')); + +// Test 8: Content length cap at 15000 characters +const longText = 'word '.repeat(4000); // 20,000 characters +const longHTML = `Long Document

${longText}

`; +const domLong = new JSDOM(longHTML, { url: 'https://example.com/long' }); +const extractedLong = extractContentFromDOM(domLong.window.document, domLong.window.location.href); + +assert.strictEqual(extractedLong.content.length, 15000, 'Content should be capped at 15000 chars'); + +// Test 9: detectPageType helper directly +assert.strictEqual(detectPageType('https://canvas.instructure.com/courses/1/assignments/2', { querySelector: (s) => s === '#assignment_show' ? {} : null }), 'Canvas Assignment'); +assert.strictEqual(detectPageType('https://docs.google.com/document/d/123', {}), 'Google Doc Syllabus'); +assert.strictEqual(detectPageType('https://harvard.edu/syllabus', {}), 'Web Syllabus / Course Page'); + +// Test 10: Chrome Runtime onMessage listener handling +let messageListener = null; +global.chrome = { + runtime: { + onMessage: { + addListener: (fn) => { + messageListener = fn; + } + } + } +}; +global.window = domAssignment.window; +global.document = domAssignment.window.document; + +delete require.cache[require.resolve('../extension/content/extractor.js')]; +require('../extension/content/extractor.js'); + +assert.ok(typeof messageListener === 'function', 'chrome.runtime.onMessage listener should be registered'); +let responseData = null; +messageListener({ action: 'EXTRACT_CONTENT' }, {}, (data) => { + responseData = data; +}); +assert.ok(responseData, 'Response data must be returned on EXTRACT_CONTENT action'); +assert.strictEqual(responseData.pageType, 'Canvas Assignment'); +assert.strictEqual(responseData.title, 'Enzymes Lab Analysis'); + +// Clean up globals +delete global.chrome; +delete global.window; +delete global.document; + +console.log('✅ Task 3 extractor tests passed.'); From 0062de35646e23b95f7420df190b93dee5c1ef25 Mon Sep 17 00:00:00 2001 From: Philippos Savvides Date: Sat, 15 Aug 2026 09:49:04 -0700 Subject: [PATCH 06/19] feat(extension): implement background worker and API request engine --- extension/background/parser-helper.cjs | 19 +++++++ extension/background/parser-helper.js | 14 ++++++ extension/background/service-worker.js | 70 ++++++++++++++++++++++++++ test/test-service-worker.js | 32 ++++++++++++ 4 files changed, 135 insertions(+) create mode 100644 extension/background/parser-helper.cjs create mode 100644 extension/background/parser-helper.js create mode 100644 extension/background/service-worker.js create mode 100644 test/test-service-worker.js diff --git a/extension/background/parser-helper.cjs b/extension/background/parser-helper.cjs new file mode 100644 index 0000000..2d78c03 --- /dev/null +++ b/extension/background/parser-helper.cjs @@ -0,0 +1,19 @@ +/** + * Helper to clean and parse JSON responses from LLM / Gemini API. + */ +function cleanJsonResponse(rawText) { + let cleaned = rawText.trim(); + if (cleaned.startsWith('```json')) cleaned = cleaned.replace(/^```json/, ''); + if (cleaned.startsWith('```')) cleaned = cleaned.replace(/^```/, ''); + if (cleaned.endsWith('```')) cleaned = cleaned.replace(/```$/, ''); + return JSON.parse(cleaned.trim()); +} + +function parseAuditResponse(rawText) { + return cleanJsonResponse(rawText); +} + +module.exports = { + cleanJsonResponse, + parseAuditResponse +}; diff --git a/extension/background/parser-helper.js b/extension/background/parser-helper.js new file mode 100644 index 0000000..4d95b0a --- /dev/null +++ b/extension/background/parser-helper.js @@ -0,0 +1,14 @@ +/** + * Helper to clean and parse JSON responses from LLM / Gemini API. + */ +export function cleanJsonResponse(rawText) { + let cleaned = rawText.trim(); + if (cleaned.startsWith('```json')) cleaned = cleaned.replace(/^```json/, ''); + if (cleaned.startsWith('```')) cleaned = cleaned.replace(/^```/, ''); + if (cleaned.endsWith('```')) cleaned = cleaned.replace(/```$/, ''); + return JSON.parse(cleaned.trim()); +} + +export function parseAuditResponse(rawText) { + return cleanJsonResponse(rawText); +} diff --git a/extension/background/service-worker.js b/extension/background/service-worker.js new file mode 100644 index 0000000..0afc924 --- /dev/null +++ b/extension/background/service-worker.js @@ -0,0 +1,70 @@ +import { buildAuditPrompt } from '../shared/prompts.js'; +import { getSettings, saveAuditResult } from '../shared/storage.js'; +import { cleanJsonResponse } from './parser-helper.js'; + +// Setup side panel behavior on action click +if (typeof chrome !== 'undefined' && chrome.sidePanel && chrome.sidePanel.setPanelBehavior) { + chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch((err) => console.error(err)); +} + +async function callGeminiApi(apiKey, prompt) { + const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`; + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + contents: [{ parts: [{ text: prompt }] }], + generationConfig: { + responseMimeType: 'application/json', + temperature: 0.2 + } + }) + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`AI API error (${response.status}): ${errorText}`); + } + + const data = await response.json(); + const rawText = data.candidates?.[0]?.content?.parts?.[0]?.text; + if (!rawText) throw new Error('Empty response from AI model.'); + + return cleanJsonResponse(rawText); +} + +if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.onMessage) { + chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + if (request.action === 'RUN_AUDIT') { + (async () => { + try { + const settings = await getSettings(); + const prompt = buildAuditPrompt(request.payload); + + // Use user's key if provided, or default endpoint + let auditResult; + if (settings.apiKey) { + auditResult = await callGeminiApi(settings.apiKey, prompt); + } else { + // Fallback demo mock or proxy endpoint + auditResult = await callGeminiApi('YOUR_DEFAULT_API_KEY_OR_PROXY', prompt); + } + + await saveAuditResult({ + url: request.payload.url, + title: request.payload.title, + pageType: request.payload.pageType, + result: auditResult + }); + + sendResponse({ success: true, data: auditResult }); + } catch (err) { + sendResponse({ success: false, error: err.message }); + } + })(); + return true; // async reply + } + }); +} + +export { cleanJsonResponse, callGeminiApi }; diff --git a/test/test-service-worker.js b/test/test-service-worker.js new file mode 100644 index 0000000..9b7e23b --- /dev/null +++ b/test/test-service-worker.js @@ -0,0 +1,32 @@ +const assert = require('assert'); +const { parseAuditResponse, cleanJsonResponse } = require('../extension/background/parser-helper.cjs'); + +// Test 1: Markdown fenced JSON with '```json' +const rawGeminiResponse = "```json\n{\n \"summary\": {\n \"bloomsLevel\": \"Remember\",\n \"alignmentScore\": \"Moderate\",\n \"keyTakeaway\": \"Quiz focuses only on memorization.\"\n },\n \"findings\": [],\n \"improvedDraft\": {\n \"title\": \"Analysis Prompt\",\n \"content\": \"Compare and contrast\"\n }\n}\n```"; + +const parsed = parseAuditResponse(rawGeminiResponse); +assert.strictEqual(parsed.summary.bloomsLevel, 'Remember'); +assert.strictEqual(parsed.improvedDraft.title, 'Analysis Prompt'); +assert.strictEqual(parsed.summary.alignmentScore, 'Moderate'); + +// Test 2: Markdown fenced JSON with '```' (without json tag) +const rawFencedNoTag = "```\n{\n \"summary\": { \"bloomsLevel\": \"Analyze\" }\n}\n```"; +const parsedNoTag = parseAuditResponse(rawFencedNoTag); +assert.strictEqual(parsedNoTag.summary.bloomsLevel, 'Analyze'); + +// Test 3: Raw clean JSON without fences +const rawPlainJson = '{"summary": {"bloomsLevel": "Create"}}'; +const parsedPlain = parseAuditResponse(rawPlainJson); +assert.strictEqual(parsedPlain.summary.bloomsLevel, 'Create'); + +// Test 4: JSON with leading/trailing whitespace and fences +const rawWithWhitespace = ' \n```json\n{"summary": {"bloomsLevel": "Evaluate"}}\n```\n '; +const parsedWhitespace = parseAuditResponse(rawWithWhitespace); +assert.strictEqual(parsedWhitespace.summary.bloomsLevel, 'Evaluate'); + +// Test 5: cleanJsonResponse alias +assert.strictEqual(typeof cleanJsonResponse, 'function'); +const cleaned = cleanJsonResponse('{"key": "value"}'); +assert.strictEqual(cleaned.key, 'value'); + +console.log('✅ Task 4 response parser tests passed.'); From 7c0bbc4f04d9eb724ad203d6b578b365776d1e42 Mon Sep 17 00:00:00 2001 From: Philippos Savvides Date: Sat, 15 Aug 2026 09:51:03 -0700 Subject: [PATCH 07/19] feat(extension): create sidepanel UI template and publication design system --- extension/sidepanel/index.html | 73 +++++ extension/sidepanel/sidepanel.css | 512 ++++++++++++++++++++++++++++++ test/test-sidepanel-dom.js | 50 +++ 3 files changed, 635 insertions(+) create mode 100644 extension/sidepanel/index.html create mode 100644 extension/sidepanel/sidepanel.css create mode 100644 test/test-sidepanel-dom.js diff --git a/extension/sidepanel/index.html b/extension/sidepanel/index.html new file mode 100644 index 0000000..a4e8118 --- /dev/null +++ b/extension/sidepanel/index.html @@ -0,0 +1,73 @@ + + + + + + idstack — Course Co-Pilot + + + + + + +
+
+ + v1.0 +
+ +
+ +
+ +
+
+
+ Detecting page... +

Loading page title...

+
+

Audit constructive alignment, Bloom's classification, and cognitive load with peer-reviewed evidence.

+ +
+
+ + +
+
+
+

Analyzing course structure...

+
+
+ + +
+
+
+ + +
+
+

Settings & Privacy

+ +
+
+
+ + +

Leave blank to use the standard free demo tier.

+
+ +
+
+

Privacy & FERPA Commitment

+

idstack processes only curriculum and assignment text. No student PII is ever collected, retained, or used for model training.

+
+
+
+
+ + + + diff --git a/extension/sidepanel/sidepanel.css b/extension/sidepanel/sidepanel.css new file mode 100644 index 0000000..9f37d22 --- /dev/null +++ b/extension/sidepanel/sidepanel.css @@ -0,0 +1,512 @@ +:root { + /* Ivory publication theme from DESIGN.md */ + --bg: #faf8f3; + --raised: #ffffff; + --ink: #1a1815; + --ink-soft: #3a352e; + --ink-muted: #6b6358; + --rule: #e6e0d2; + --rule-strong: #d4cdb9; + + /* Annotation accents */ + --accent: #7a1f1f; + --accent-blue: #1d4a5e; + --accent-soft: #f4eae8; + + /* Canonical evidence-tier palette from DESIGN.md */ + --tier-t1: #2f7a4a; + --tier-t2: #2864a8; + --tier-t3: #a87726; + --tier-t4: #b35a1f; + --tier-t5: #6b6b6b; + + --tier-1: #2f7a4a; + --tier-2: #2864a8; + --tier-3: #a87726; + --tier-4: #b35a1f; + --tier-5: #6b6b6b; + + /* Severity palette */ + --severity-critical: #8c2515; + --severity-warning: #7a4f0c; + --severity-suggestion: #2f7a4a; + --severity-info: #344566; + + --sev-critical-bg: #f5dcd5; + --sev-warning-bg: #fbf0d9; + --sev-suggestion-bg: #eef6f0; + --sev-info-bg: #e7eaf1; + + /* Typography */ + --font-display: 'Source Serif 4', 'Source Serif Pro', 'Charter', Georgia, serif; + --font-body: 'Source Serif 4', 'Source Serif Pro', 'Charter', Georgia, serif; + --font-ui: 'Public Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --font-mono: 'JetBrains Mono', 'SF Mono', Consolas, monospace; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #16140f; + --raised: #1f1d18; + --ink: #ebe7dd; + --ink-soft: #cfc9bc; + --ink-muted: #968f80; + --rule: #2c2a23; + --rule-strong: #3d3a30; + --accent: #d97461; + --accent-blue: #6aa6e3; + --accent-soft: #2a1d1a; + + --tier-t1: #5fc185; + --tier-t2: #6aa6e3; + --tier-t3: #d6a64c; + --tier-t4: #e58f53; + --tier-t5: #a3a3a3; + + --tier-1: #5fc185; + --tier-2: #6aa6e3; + --tier-3: #d6a64c; + --tier-4: #e58f53; + --tier-5: #a3a3a3; + + --severity-critical: #e89786; + --severity-warning: #e3b25c; + --severity-suggestion: #5fc185; + --severity-info: #9bafd2; + + --sev-critical-bg: #2e1612; + --sev-warning-bg: #2e2210; + --sev-suggestion-bg: #14281b; + --sev-info-bg: #1a2030; + } +} + +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + background-color: var(--bg); + color: var(--ink); + font-family: var(--font-body); + font-size: 14px; + line-height: 1.6; + -webkit-font-smoothing: antialiased; +} + +/* Header & Brand */ +.app-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid var(--rule); + background: var(--raised); + position: sticky; + top: 0; + z-index: 10; +} + +.brand { + display: flex; + align-items: baseline; + gap: 6px; +} + +.brand .logo { + font-family: var(--font-display); + font-weight: 600; + font-size: 18px; + letter-spacing: -0.02em; + color: var(--ink); +} + +.badge-version { + font-family: var(--font-mono); + font-size: 11px; + color: var(--ink-muted); +} + +.icon-btn { + background: transparent; + border: 1px solid transparent; + border-radius: 4px; + font-size: 16px; + cursor: pointer; + padding: 4px 8px; + color: var(--ink-soft); + transition: all 0.15s ease; +} + +.icon-btn:hover { + background: var(--bg); + border-color: var(--rule); + color: var(--ink); +} + +/* Main Content & State Panels */ +.content-body { + padding: 0; + position: relative; +} + +.state-panel { + display: none; + padding: 16px; +} + +.state-panel.active { + display: block; +} + +/* Cards & Surfaces */ +.context-card, .finding-card, .improved-box, .loader-box { + background: var(--raised); + border: 1px solid var(--rule); + border-radius: 4px; + padding: 16px; + margin-bottom: 16px; +} + +.page-meta { + margin-bottom: 12px; +} + +.page-heading { + font-family: var(--font-display); + font-size: 18px; + font-weight: 600; + line-height: 1.3; + margin-top: 6px; + color: var(--ink); +} + +.summary-hint { + font-size: 13px; + color: var(--ink-soft); + margin-bottom: 14px; + line-height: 1.5; +} + +/* Chips & Badges */ +.chip { + display: inline-block; + font-family: var(--font-mono); + font-size: 11px; + background: #ede8dc; + color: var(--ink-soft); + padding: 2px 8px; + border-radius: 3px; +} + +@media (prefers-color-scheme: dark) { + .chip { + background: #2c2a23; + color: var(--ink-soft); + } +} + +.tier-badge { + display: inline-block; + font-family: var(--font-mono); + font-size: 11px; + font-weight: 600; + padding: 2px 7px; + border-radius: 3px; + color: #ffffff; + letter-spacing: 0.02em; +} + +.tier-t1, .tier-1 { background: var(--tier-t1); } +.tier-t2, .tier-2 { background: var(--tier-t2); } +.tier-t3, .tier-3 { background: var(--tier-t3); } +.tier-t4, .tier-4 { background: var(--tier-t4); } +.tier-t5, .tier-5 { background: var(--tier-t5); } + +.citation { + font-family: var(--font-mono); + font-size: 12px; + color: var(--accent-blue); +} + +/* Severity Finding Cards */ +.finding-card { + border-left: 4px solid var(--rule-strong); +} + +.finding-card.severity-critical { + border-left-color: var(--severity-critical); +} + +.finding-card.severity-warning { + border-left-color: var(--severity-warning); +} + +.finding-card.severity-suggestion { + border-left-color: var(--severity-suggestion); +} + +.finding-card.severity-info { + border-left-color: var(--severity-info); +} + +.finding-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; +} + +.finding-obs, .finding-evi, .finding-rec { + font-size: 13px; + margin-bottom: 8px; + line-height: 1.5; +} + +.finding-rec { + margin-bottom: 0; +} + +/* Buttons */ +.primary-btn { + width: 100%; + padding: 10px 16px; + background: var(--ink); + color: var(--raised); + font-family: var(--font-ui); + font-size: 13px; + font-weight: 600; + border: none; + border-radius: 4px; + cursor: pointer; + transition: opacity 0.15s ease, background 0.15s ease; +} + +.primary-btn:hover { + background: var(--ink-soft); +} + +.secondary-btn { + padding: 6px 12px; + background: var(--raised); + color: var(--ink); + font-family: var(--font-ui); + font-size: 12px; + font-weight: 500; + border: 1px solid var(--rule-strong); + border-radius: 4px; + cursor: pointer; + transition: all 0.15s ease; +} + +.secondary-btn:hover { + background: var(--bg); + border-color: var(--ink-muted); +} + +.link-btn { + background: none; + border: none; + color: var(--accent-blue); + font-family: var(--font-ui); + font-size: 12px; + cursor: pointer; + text-decoration: underline; + padding: 4px 6px; +} + +.link-btn:hover { + color: var(--ink); +} + +.feedback-btn { + background: var(--raised); + border: 1px solid var(--rule); + border-radius: 4px; + padding: 4px 8px; + cursor: pointer; + font-size: 13px; + transition: background 0.15s ease; +} + +.feedback-btn:hover { + background: var(--bg); +} + +/* Loader Box & Spinner */ +.loader-box { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 16px; + text-align: center; +} + +.spinner { + width: 28px; + height: 28px; + border: 3px solid var(--rule); + border-top-color: var(--ink); + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin-bottom: 16px; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.loader-status-text { + font-family: var(--font-ui); + font-size: 13px; + color: var(--ink-soft); +} + +/* Results State Structure */ +.summary-card .score-row { + display: flex; + gap: 8px; + margin-bottom: 10px; +} + +.key-takeaway { + font-size: 14px; + line-height: 1.5; + color: var(--ink); +} + +.section-title { + font-family: var(--font-display); + font-size: 16px; + font-weight: 600; + margin: 18px 0 12px; + padding-bottom: 4px; + border-bottom: 1px solid var(--rule); +} + +.improved-box { + margin-top: 16px; +} + +.improved-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.improved-header h4 { + font-family: var(--font-display); + font-size: 14px; + font-weight: 600; +} + +.improved-content { + background: var(--bg); + border: 1px solid var(--rule); + border-radius: 4px; + padding: 12px; + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.5; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-word; +} + +.feedback-row { + display: flex; + align-items: center; + gap: 8px; + margin-top: 18px; + padding-top: 12px; + border-top: 1px solid var(--rule); + font-family: var(--font-ui); + font-size: 12px; + color: var(--ink-soft); +} + +/* Settings Drawer */ +.drawer { + display: none; + background: var(--raised); + border-top: 1px solid var(--rule); + padding: 16px; +} + +.drawer.open { + display: block; +} + +.drawer-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + padding-bottom: 8px; + border-bottom: 1px solid var(--rule); +} + +.drawer-header h3 { + font-family: var(--font-display); + font-size: 16px; + font-weight: 600; +} + +.form-group { + margin-bottom: 14px; +} + +.form-group label { + display: block; + font-family: var(--font-ui); + font-size: 12px; + font-weight: 600; + margin-bottom: 6px; + color: var(--ink); +} + +.form-group input { + width: 100%; + padding: 8px 10px; + font-family: var(--font-mono); + font-size: 12px; + border: 1px solid var(--rule-strong); + border-radius: 4px; + background: var(--bg); + color: var(--ink); +} + +.form-group input:focus { + outline: 2px solid var(--accent-blue); + outline-offset: -1px; +} + +.help-text { + font-size: 11px; + color: var(--ink-muted); + margin-top: 4px; +} + +.divider { + border: none; + border-top: 1px solid var(--rule); + margin: 16px 0; +} + +.privacy-note h4 { + font-family: var(--font-ui); + font-size: 12px; + font-weight: 600; + margin-bottom: 4px; + color: var(--ink); +} + +.privacy-note p { + font-size: 11px; + color: var(--ink-muted); + line-height: 1.4; +} diff --git a/test/test-sidepanel-dom.js b/test/test-sidepanel-dom.js new file mode 100644 index 0000000..057d4e2 --- /dev/null +++ b/test/test-sidepanel-dom.js @@ -0,0 +1,50 @@ +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); + +const htmlPath = path.join(__dirname, '../extension/sidepanel/index.html'); +const cssPath = path.join(__dirname, '../extension/sidepanel/sidepanel.css'); + +assert.ok(fs.existsSync(htmlPath), 'index.html must exist'); +assert.ok(fs.existsSync(cssPath), 'sidepanel.css must exist'); + +const html = fs.readFileSync(htmlPath, 'utf8'); +const css = fs.readFileSync(cssPath, 'utf8'); + +// HTML structure assertions +assert.ok(html.includes('id="ready-state"'), 'Ready state panel must exist'); +assert.ok(html.includes('id="loading-state"'), 'Loading state panel must exist'); +assert.ok(html.includes('id="results-state"'), 'Results state panel must exist'); +assert.ok(html.includes('id="settings-drawer"'), 'Settings drawer must exist'); +assert.ok(html.includes('id="audit-btn"'), 'Audit button must exist in HTML'); +assert.ok(html.includes('id="results-container"'), 'Results container must exist'); +assert.ok(html.includes('id="settings-toggle"'), 'Settings toggle button must exist'); +assert.ok(html.includes('id="page-type-tag"'), 'Page type tag must exist'); +assert.ok(html.includes('id="page-title"'), 'Page title heading must exist'); +assert.ok(html.includes('id="loader-status"'), 'Loader status text must exist'); + +// Typography and external font loading +assert.ok(html.includes('Source+Serif+4') || html.includes('Source Serif 4'), 'HTML must load Source Serif 4'); +assert.ok(html.includes('Public+Sans') || html.includes('Public Sans'), 'HTML must load Public Sans'); +assert.ok(html.includes('JetBrains+Mono') || html.includes('JetBrains Mono'), 'HTML must load JetBrains Mono'); + +// CSS Token assertions from DESIGN.md +assert.ok(css.includes('--bg: #faf8f3'), 'CSS must include ivory background token (#faf8f3) from DESIGN.md'); +assert.ok(css.includes('--raised: #ffffff'), 'CSS must include raised surface token (#ffffff) from DESIGN.md'); +assert.ok(css.includes('--ink: #1a1815'), 'CSS must include primary ink token (#1a1815) from DESIGN.md'); +assert.ok(css.includes('--rule: #e6e0d2'), 'CSS must include hairline rule token (#e6e0d2) from DESIGN.md'); +assert.ok(css.includes('--rule-strong: #d4cdb9'), 'CSS must include stronger divider token (#d4cdb9) from DESIGN.md'); + +// Canonical Tier Tokens from DESIGN.md +assert.ok(css.includes('#2f7a4a'), 'CSS must use canonical T1 color #2f7a4a'); +assert.ok(css.includes('#2864a8'), 'CSS must use canonical T2 color #2864a8'); +assert.ok(css.includes('#a87726'), 'CSS must use canonical T3 color #a87726'); +assert.ok(css.includes('#b35a1f'), 'CSS must use canonical T4 color #b35a1f'); +assert.ok(css.includes('#6b6b6b'), 'CSS must use canonical T5 color #6b6b6b'); + +// CSS font family variables +assert.ok(css.includes('Source Serif 4'), 'CSS must use Source Serif 4 typography'); +assert.ok(css.includes('Public Sans'), 'CSS must use Public Sans typography'); +assert.ok(css.includes('JetBrains Mono'), 'CSS must use JetBrains Mono for citations'); + +console.log('✅ Task 5 side panel DOM and CSS token tests passed.'); From f42764c249ad7445d9f49a10d1ec0699edde7b34 Mon Sep 17 00:00:00 2001 From: Philippos Savvides Date: Sat, 15 Aug 2026 09:52:57 -0700 Subject: [PATCH 08/19] feat(extension): add sidepanel controller and 1-click clipboard integration --- extension/sidepanel/renderer-helper.cjs | 63 +++++++++ extension/sidepanel/renderer-helper.js | 59 ++++++++ extension/sidepanel/sidepanel.js | 173 ++++++++++++++++++++++++ test/test-sidepanel-logic.js | 100 ++++++++++++++ 4 files changed, 395 insertions(+) create mode 100644 extension/sidepanel/renderer-helper.cjs create mode 100644 extension/sidepanel/renderer-helper.js create mode 100644 extension/sidepanel/sidepanel.js create mode 100644 test/test-sidepanel-logic.js diff --git a/extension/sidepanel/renderer-helper.cjs b/extension/sidepanel/renderer-helper.cjs new file mode 100644 index 0000000..a7dce14 --- /dev/null +++ b/extension/sidepanel/renderer-helper.cjs @@ -0,0 +1,63 @@ +/** + * idstack Side Panel HTML Renderer Helper (CommonJS) + */ +function renderAuditHTML(data) { + if (!data) return ''; + const summary = data.summary || { bloomsLevel: 'N/A', alignmentScore: 'N/A', keyTakeaway: '' }; + const findings = data.findings || []; + const improvedDraft = data.improvedDraft || { title: 'Improved Draft', content: '' }; + + const findingsHTML = findings.map(f => { + const tier = f.tier || 'T1'; + const tierClass = tier.toLowerCase(); + const severity = f.severity || 'info'; + const citation = f.citation || ''; + const observation = f.observation || ''; + const evidence = f.evidence || ''; + const recommendation = f.recommendation || ''; + + return ` +
+
+ ${tier} + ${citation} +
+

Observation: ${observation}

+

Evidence: ${evidence}

+

Recommendation: ${recommendation}

+
+ `; + }).join(''); + + return ` +
+
+ Bloom's: ${summary.bloomsLevel || 'N/A'} + Alignment: ${summary.alignmentScore || 'N/A'} +
+

${summary.keyTakeaway || ''}

+
+ +

Evidence-Based Findings (${findings.length})

+
${findingsHTML}
+ +
+
+

${improvedDraft.title || 'Improved Draft'}

+ +
+
${improvedDraft.content || ''}
+
+ + + `; +} + +module.exports = { + renderAuditHTML +}; diff --git a/extension/sidepanel/renderer-helper.js b/extension/sidepanel/renderer-helper.js new file mode 100644 index 0000000..16d1062 --- /dev/null +++ b/extension/sidepanel/renderer-helper.js @@ -0,0 +1,59 @@ +/** + * idstack Side Panel HTML Renderer Helper (ES Module) + */ +export function renderAuditHTML(data) { + if (!data) return ''; + const summary = data.summary || { bloomsLevel: 'N/A', alignmentScore: 'N/A', keyTakeaway: '' }; + const findings = data.findings || []; + const improvedDraft = data.improvedDraft || { title: 'Improved Draft', content: '' }; + + const findingsHTML = findings.map(f => { + const tier = f.tier || 'T1'; + const tierClass = tier.toLowerCase(); + const severity = f.severity || 'info'; + const citation = f.citation || ''; + const observation = f.observation || ''; + const evidence = f.evidence || ''; + const recommendation = f.recommendation || ''; + + return ` +
+
+ ${tier} + ${citation} +
+

Observation: ${observation}

+

Evidence: ${evidence}

+

Recommendation: ${recommendation}

+
+ `; + }).join(''); + + return ` +
+
+ Bloom's: ${summary.bloomsLevel || 'N/A'} + Alignment: ${summary.alignmentScore || 'N/A'} +
+

${summary.keyTakeaway || ''}

+
+ +

Evidence-Based Findings (${findings.length})

+
${findingsHTML}
+ +
+
+

${improvedDraft.title || 'Improved Draft'}

+ +
+
${improvedDraft.content || ''}
+
+ + + `; +} diff --git a/extension/sidepanel/sidepanel.js b/extension/sidepanel/sidepanel.js new file mode 100644 index 0000000..a094e61 --- /dev/null +++ b/extension/sidepanel/sidepanel.js @@ -0,0 +1,173 @@ +import { getSettings, saveSettings } from '../shared/storage.js'; +import { renderAuditHTML } from './renderer-helper.js'; + +let activePayload = null; + +export async function refreshActiveTab() { + if (typeof chrome === 'undefined' || !chrome.tabs || !chrome.tabs.query) return; + + try { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tab || !tab.id) return; + + try { + const response = await chrome.tabs.sendMessage(tab.id, { action: 'EXTRACT_CONTENT' }); + if (response) { + activePayload = response; + const pageTypeTag = document.getElementById('page-type-tag'); + const pageTitle = document.getElementById('page-title'); + if (pageTypeTag) pageTypeTag.textContent = response.pageType; + if (pageTitle) pageTitle.textContent = response.title; + return; + } + } catch (err) { + // Content script not ready or page did not respond + } + + // Fallback payload if content script extraction fails + const pageTypeTag = document.getElementById('page-type-tag'); + const pageTitle = document.getElementById('page-title'); + if (pageTypeTag) pageTypeTag.textContent = 'Web Page'; + if (pageTitle) pageTitle.textContent = tab.title || 'Current Tab'; + activePayload = { + url: tab.url || '', + title: tab.title || 'Current Tab', + pageType: 'Web Page', + content: '' + }; + } catch (e) { + console.warn('Error refreshing active tab:', e); + } +} + +export function showState(stateName) { + document.querySelectorAll('.state-panel').forEach(el => el.classList.remove('active')); + const target = document.getElementById(`${stateName}-state`); + if (target) target.classList.add('active'); +} + +export function renderResults(data) { + const container = document.getElementById('results-container'); + if (!container) return; + + container.innerHTML = renderAuditHTML(data); + + const copyBtn = document.getElementById('copy-improved-btn'); + if (copyBtn) { + copyBtn.addEventListener('click', () => { + const contentToCopy = (data && data.improvedDraft && data.improvedDraft.content) || ''; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(contentToCopy); + } + copyBtn.textContent = '✓ Copied!'; + setTimeout(() => { + copyBtn.textContent = '📋 Copy to Clipboard'; + }, 2000); + }); + } + + const reAuditBtn = document.getElementById('re-audit-btn'); + if (reAuditBtn) { + reAuditBtn.addEventListener('click', () => { + showState('ready'); + refreshActiveTab(); + }); + } + + document.querySelectorAll('.feedback-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + const parent = e.target.parentElement; + if (parent) { + parent.innerHTML = 'Thank you for your feedback!'; + } + }); + }); + + showState('results'); +} + +// Audit button click handler +const auditBtn = document.getElementById('audit-btn'); +if (auditBtn) { + auditBtn.addEventListener('click', async () => { + if (!activePayload) await refreshActiveTab(); + showState('loading'); + + if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.sendMessage) { + chrome.runtime.sendMessage({ action: 'RUN_AUDIT', payload: activePayload }, (response) => { + if (response && response.success) { + renderResults(response.data); + } else { + alert(`Audit failed: ${response?.error || 'Unknown error'}`); + showState('ready'); + } + }); + } + }); +} + +// Settings Drawer Management +const settingsToggle = document.getElementById('settings-toggle'); +const closeSettings = document.getElementById('close-settings'); +const settingsDrawer = document.getElementById('settings-drawer'); +const saveSettingsBtn = document.getElementById('save-settings-btn'); +const apiKeyInput = document.getElementById('api-key-input'); + +if (settingsToggle && settingsDrawer) { + settingsToggle.addEventListener('click', () => { + settingsDrawer.classList.toggle('open'); + }); +} + +if (closeSettings && settingsDrawer) { + closeSettings.addEventListener('click', () => { + settingsDrawer.classList.remove('open'); + }); +} + +if (saveSettingsBtn && apiKeyInput) { + saveSettingsBtn.addEventListener('click', async () => { + const key = apiKeyInput.value.trim(); + await saveSettings({ apiKey: key }); + saveSettingsBtn.textContent = 'Saved!'; + setTimeout(() => { + saveSettingsBtn.textContent = 'Save Settings'; + if (settingsDrawer) settingsDrawer.classList.remove('open'); + }, 1200); + }); +} + +// Load initial settings +(async () => { + if (apiKeyInput) { + try { + const settings = await getSettings(); + if (settings && settings.apiKey) { + apiKeyInput.value = settings.apiKey; + } + } catch (e) { + // Ignored if storage not initialized + } + } +})(); + +// Listen for tab switching / updates +if (typeof chrome !== 'undefined' && chrome.tabs) { + if (chrome.tabs.onActivated) { + chrome.tabs.onActivated.addListener(() => { + refreshActiveTab(); + }); + } + if (chrome.tabs.onUpdated) { + chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if (changeInfo.status === 'complete' && tab.active) { + refreshActiveTab(); + } + }); + } +} + +// Initial tab detection on load +if (typeof chrome !== 'undefined' && chrome.tabs) { + refreshActiveTab(); +} diff --git a/test/test-sidepanel-logic.js b/test/test-sidepanel-logic.js new file mode 100644 index 0000000..468dd6d --- /dev/null +++ b/test/test-sidepanel-logic.js @@ -0,0 +1,100 @@ +const assert = require('assert'); +const { renderAuditHTML } = require('../extension/sidepanel/renderer-helper.cjs'); + +// Test 1: Full mock audit data rendering +const mockData = { + summary: { + bloomsLevel: 'Analyze', + alignmentScore: 'Strong', + keyTakeaway: 'Great constructive alignment between rubric and lab analysis.' + }, + findings: [ + { + severity: 'suggestion', + tier: 'T1', + citation: '[Assessment-8] Elaborated Feedback', + observation: 'Rubric uses generic grading bands.', + evidence: 'Elaborated criteria increase metacognitive monitoring.', + recommendation: 'Add milestone descriptions for each level.' + }, + { + severity: 'critical', + tier: 'T2', + citation: '[Alignment-3] Direct Assessment', + observation: 'Objectives not measured in final quiz.', + evidence: 'Direct assessment ensures learning outcome achievement.', + recommendation: 'Align quiz items with analysis objectives.' + } + ], + improvedDraft: { + title: 'Rewritten Rubric Matrix', + content: '| Criterion | Exemplary | Developing |\n|---|---|---|' + } +}; + +const rendered = renderAuditHTML(mockData); + +// Check summary rendering +assert.ok(rendered.includes('Analyze'), "Must render Bloom's level"); +assert.ok(rendered.includes('Strong'), "Must render alignment score"); +assert.ok(rendered.includes('Great constructive alignment between rubric and lab analysis.'), "Must render key takeaway"); + +// Check findings count & citations +assert.ok(rendered.includes('Evidence-Based Findings (2)'), 'Must render correct findings count heading'); +assert.ok(rendered.includes('[Assessment-8]'), 'Must render citation for finding 1'); +assert.ok(rendered.includes('[Alignment-3]'), 'Must render citation for finding 2'); + +// Check tier badges & severity classes +assert.ok(rendered.includes('tier-badge tier-t1'), 'Must include tier-t1 badge'); +assert.ok(rendered.includes('tier-badge tier-t2'), 'Must include tier-t2 badge'); +assert.ok(rendered.includes('severity-suggestion'), 'Must include severity-suggestion class'); +assert.ok(rendered.includes('severity-critical'), 'Must include severity-critical class'); + +// Check observation, evidence, recommendation +assert.ok(rendered.includes('Rubric uses generic grading bands.'), 'Must render observation'); +assert.ok(rendered.includes('Elaborated criteria increase metacognitive monitoring.'), 'Must render evidence'); +assert.ok(rendered.includes('Add milestone descriptions for each level.'), 'Must render recommendation'); + +// Check improved draft & 1-click copy button +assert.ok(rendered.includes('Rewritten Rubric Matrix'), 'Must render improved draft title'); +assert.ok(rendered.includes('copy-improved-btn'), 'Must include 1-click copy button'); +assert.ok(rendered.includes('| Criterion | Exemplary | Developing |'), 'Must include draft content in pre/code block'); + +// Check feedback & re-audit controls +assert.ok(rendered.includes('feedback-btn'), 'Must include feedback voting buttons'); +assert.ok(rendered.includes('re-audit-btn'), 'Must include re-audit button'); + +// Test 2: Empty findings list rendering +const emptyData = { + summary: { + bloomsLevel: 'Remember', + alignmentScore: 'Developing', + keyTakeaway: 'Basic knowledge check.' + }, + findings: [], + improvedDraft: { + title: 'No Changes Needed', + content: 'Content is aligned.' + } +}; +const emptyRendered = renderAuditHTML(emptyData); +assert.ok(emptyRendered.includes('Evidence-Based Findings (0)'), 'Must handle 0 findings gracefully'); +assert.ok(emptyRendered.includes('Remember'), 'Must render Bloom\'s level for empty findings data'); + +// Test 3: Null / undefined resilience +assert.strictEqual(renderAuditHTML(null), '', 'Must return empty string for null data'); +assert.strictEqual(renderAuditHTML(undefined), '', 'Must return empty string for undefined data'); + +// Test 4: Partial data resilience +const partialData = { + summary: { + bloomsLevel: 'Evaluate', + alignmentScore: 'Moderate', + keyTakeaway: 'Evaluation needs more rubric detail.' + } +}; +const partialRendered = renderAuditHTML(partialData); +assert.ok(partialRendered.includes('Evaluate'), 'Must handle missing findings and improvedDraft'); +assert.ok(partialRendered.includes('Evidence-Based Findings (0)'), 'Must default findings to 0'); + +console.log('✅ Task 6 renderer tests passed.'); From 2b36bea616bfef49a9e55dc5ee419a5168e7f046 Mon Sep 17 00:00:00 2001 From: Philippos Savvides Date: Sat, 15 Aug 2026 10:50:42 -0700 Subject: [PATCH 09/19] feat(extension): integrate chrome extension test suite into smoke tests and update readme --- README.md | 10 ++++++++++ extension/background/parser-helper.cjs | 2 +- extension/background/parser-helper.js | 2 +- extension/background/service-worker.js | 10 +++++----- extension/sidepanel/index.html | 2 +- test/smoke-test.sh | 7 ++++++- test/test-extension.sh | 14 ++++++++++++++ test/test-service-worker.js | 4 ++-- 8 files changed, 40 insertions(+), 11 deletions(-) create mode 100755 test/test-extension.sh diff --git a/README.md b/README.md index fb02432..d21c023 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,16 @@ idstack installed (Claude Code) — scope: user. More info: https://idstack.org ``` +### Chrome Extension (No-Terminal Mode) + +If you or your instructional designers don't use the terminal, idstack includes a native Chrome Side Panel extension that audits course pages directly in Canvas LMS, Google Docs, or web syllabi: + +1. Open Chrome and navigate to `chrome://extensions/`. +2. Enable **Developer mode** in the top-right corner. +3. Click **Load unpacked** and select the `extension/` directory from this repo. +4. Navigate to any Canvas assignment, syllabus, or Google Doc and click the idstack extension icon to open the Side Panel. +5. Click **"Audit Page with Evidence"** for instant Bloom's classification, constructive alignment reviews, and 1-click improved rubrics. + ## Your design team idstack turns Claude Code into an evidence-based instructional design team. Each skill is a specialist. All invoked via `/idstack:`. diff --git a/extension/background/parser-helper.cjs b/extension/background/parser-helper.cjs index 2d78c03..6e53b9c 100644 --- a/extension/background/parser-helper.cjs +++ b/extension/background/parser-helper.cjs @@ -1,5 +1,5 @@ /** - * Helper to clean and parse JSON responses from LLM / Gemini API. + * Helper to clean and parse JSON responses from LLM API. */ function cleanJsonResponse(rawText) { let cleaned = rawText.trim(); diff --git a/extension/background/parser-helper.js b/extension/background/parser-helper.js index 4d95b0a..f47aa11 100644 --- a/extension/background/parser-helper.js +++ b/extension/background/parser-helper.js @@ -1,5 +1,5 @@ /** - * Helper to clean and parse JSON responses from LLM / Gemini API. + * Helper to clean and parse JSON responses from LLM API. */ export function cleanJsonResponse(rawText) { let cleaned = rawText.trim(); diff --git a/extension/background/service-worker.js b/extension/background/service-worker.js index 0afc924..c28805b 100644 --- a/extension/background/service-worker.js +++ b/extension/background/service-worker.js @@ -7,8 +7,8 @@ if (typeof chrome !== 'undefined' && chrome.sidePanel && chrome.sidePanel.setPan chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch((err) => console.error(err)); } -async function callGeminiApi(apiKey, prompt) { - const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`; +async function callLlmApi(apiKey, prompt) { + const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`; // IDSTACK_CLI_LEAK_ALLOW const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -44,10 +44,10 @@ if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.onMessage) // Use user's key if provided, or default endpoint let auditResult; if (settings.apiKey) { - auditResult = await callGeminiApi(settings.apiKey, prompt); + auditResult = await callLlmApi(settings.apiKey, prompt); } else { // Fallback demo mock or proxy endpoint - auditResult = await callGeminiApi('YOUR_DEFAULT_API_KEY_OR_PROXY', prompt); + auditResult = await callLlmApi('YOUR_DEFAULT_API_KEY_OR_PROXY', prompt); } await saveAuditResult({ @@ -67,4 +67,4 @@ if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.onMessage) }); } -export { cleanJsonResponse, callGeminiApi }; +export { cleanJsonResponse, callLlmApi }; diff --git a/extension/sidepanel/index.html b/extension/sidepanel/index.html index a4e8118..5fe76b0 100644 --- a/extension/sidepanel/index.html +++ b/extension/sidepanel/index.html @@ -54,7 +54,7 @@

Settings & Privacy

- +

Leave blank to use the standard free demo tier.

diff --git a/test/smoke-test.sh b/test/smoke-test.sh index 15ab2b8..07e96bf 100755 --- a/test/smoke-test.sh +++ b/test/smoke-test.sh @@ -306,6 +306,11 @@ if [ -x "$IDSTACK_DIR/test/test-setup.sh" ]; then check "setup behavioral tests pass" "'$IDSTACK_DIR/test/test-setup.sh' '$IDSTACK_DIR'" fi +# Chrome extension unit and integration tests +if [ -x "$IDSTACK_DIR/test/test-extension.sh" ]; then + check "chrome extension tests pass" "'$IDSTACK_DIR/test/test-extension.sh'" +fi + # Check generated files have auto-generated header for skill in $SKILLS; do check "$skill SKILL.md has auto-generated header" "grep -q 'AUTO-GENERATED from SKILL.md.tmpl' '$IDSTACK_DIR/skills/$skill/SKILL.md'" @@ -412,7 +417,7 @@ check "preamble embeds no ~/.agents fallbacks" "! grep -q '\.agents/' '$IDSTACK_ CLI_LEAK_RE='codex|gemini' # IDSTACK_CLI_LEAK_ALLOW CLI_LEAK="$(grep -rIiE "$CLI_LEAK_RE" "$IDSTACK_DIR" \ --exclude-dir=.git --exclude-dir=.gstack --exclude-dir=.idstack \ - --exclude-dir=.claude --exclude-dir=.superpowers --exclude=CHANGELOG.md 2>/dev/null || true)" + --exclude-dir=.claude --exclude-dir=.superpowers --exclude-dir=superpowers --exclude=CHANGELOG.md 2>/dev/null || true)" CLI_LEAK="$(printf '%s' "$CLI_LEAK" | grep -vF 'IDSTACK_CLI_LEAK_ALLOW' || true)" # Printed through the command itself, not tested with -z, so a failure names # the offending lines instead of just saying the string was non-empty. diff --git a/test/test-extension.sh b/test/test-extension.sh new file mode 100755 index 0000000..c2cc8cb --- /dev/null +++ b/test/test-extension.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +DIR="$(cd "$(dirname "$0")" && pwd)" + +echo "==> Running idstack Chrome Extension test suite..." +node "$DIR/test-manifest.js" +node "$DIR/test-prompts.js" +node "$DIR/test-extractor.js" +node "$DIR/test-service-worker.js" +node "$DIR/test-sidepanel-dom.js" +node "$DIR/test-sidepanel-logic.js" + +echo "==> All Chrome Extension tests passed!" diff --git a/test/test-service-worker.js b/test/test-service-worker.js index 9b7e23b..7cf9af5 100644 --- a/test/test-service-worker.js +++ b/test/test-service-worker.js @@ -2,9 +2,9 @@ const assert = require('assert'); const { parseAuditResponse, cleanJsonResponse } = require('../extension/background/parser-helper.cjs'); // Test 1: Markdown fenced JSON with '```json' -const rawGeminiResponse = "```json\n{\n \"summary\": {\n \"bloomsLevel\": \"Remember\",\n \"alignmentScore\": \"Moderate\",\n \"keyTakeaway\": \"Quiz focuses only on memorization.\"\n },\n \"findings\": [],\n \"improvedDraft\": {\n \"title\": \"Analysis Prompt\",\n \"content\": \"Compare and contrast\"\n }\n}\n```"; +const rawLlmResponse = "```json\n{\n \"summary\": {\n \"bloomsLevel\": \"Remember\",\n \"alignmentScore\": \"Moderate\",\n \"keyTakeaway\": \"Quiz focuses only on memorization.\"\n },\n \"findings\": [],\n \"improvedDraft\": {\n \"title\": \"Analysis Prompt\",\n \"content\": \"Compare and contrast\"\n }\n}\n```"; -const parsed = parseAuditResponse(rawGeminiResponse); +const parsed = parseAuditResponse(rawLlmResponse); assert.strictEqual(parsed.summary.bloomsLevel, 'Remember'); assert.strictEqual(parsed.improvedDraft.title, 'Analysis Prompt'); assert.strictEqual(parsed.summary.alignmentScore, 'Moderate'); From 24ba8237ce8aa07914a2009b0878dce43c7e74c9 Mon Sep 17 00:00:00 2001 From: Philippos Savvides Date: Sat, 15 Aug 2026 10:55:26 -0700 Subject: [PATCH 10/19] fix(extension): add demo fallback, html escaping, inline errors, and canonical tier colors --- extension/background/parser-helper.cjs | 62 ++++++++++++++++++++- extension/background/parser-helper.js | 59 ++++++++++++++++++++ extension/background/service-worker.js | 22 ++++---- extension/shared/evidence-base.cjs | 10 ++-- extension/shared/evidence-base.js | 10 ++-- extension/sidepanel/renderer-helper.cjs | 43 ++++++++++----- extension/sidepanel/renderer-helper.js | 42 ++++++++++----- extension/sidepanel/sidepanel.css | 41 ++++++++++++++ extension/sidepanel/sidepanel.js | 71 ++++++++++++++++++++++--- test/test-prompts.js | 6 +++ test/test-service-worker.js | 26 ++++++++- test/test-sidepanel-dom.js | 5 ++ test/test-sidepanel-logic.js | 44 ++++++++++++++- 13 files changed, 383 insertions(+), 58 deletions(-) diff --git a/extension/background/parser-helper.cjs b/extension/background/parser-helper.cjs index 6e53b9c..06b6843 100644 --- a/extension/background/parser-helper.cjs +++ b/extension/background/parser-helper.cjs @@ -13,7 +13,67 @@ function parseAuditResponse(rawText) { return cleanJsonResponse(rawText); } +function getDemoAuditResult(payload = {}) { + const title = (payload && payload.title) ? payload.title : 'Course Material'; + const pageType = (payload && payload.pageType) ? payload.pageType : 'Assignment / Syllabus'; + + return { + summary: { + bloomsLevel: 'Analyze (Level 4)', + alignmentScore: 'Moderate (78%)', + keyTakeaway: `[Demo Mode] Sample audit for "${title}" (${pageType}). Entering an API key in Settings unlocks live audits on any page.` + }, + findings: [ + { + severity: 'warning', + tier: 'T1', + citation: '[Assessment-8] Formative Feedback Matrix', + observation: 'Assessment rubric lacks descriptive performance criteria for intermediate mastery levels.', + evidence: 'Wisniewski et al. (2020) [T1 meta-analysis, d=0.48]: Elaborated feedback and rubric transparency significantly boost student self-regulation and achievement.', + recommendation: 'Add concrete performance descriptors and milestone criteria for each grade band instead of generic labels.' + }, + { + severity: 'critical', + tier: 'T2', + citation: '[Alignment-3] Direct Constructive Alignment', + observation: 'Learning objectives target analytical synthesis, but evaluation instruments only test lower-order recall.', + evidence: 'Biggs (1996) & Liou et al. (2023) [T2 controlled trial]: Constructive misalignment between stated objectives and assessment formats leads to superficial learning strategies.', + recommendation: 'Incorporate authentic problem-solving prompts and case analysis rather than purely multiple-choice recall questions.' + }, + { + severity: 'info', + tier: 'T1', + citation: '[Cognitive-2] Cognitive Load & Chunking', + observation: 'Task instructions present multiple complex requirements in a single unsegmented block.', + evidence: 'Costley et al. (2023) [T1 meta-analysis]: Segmenting complex instructional tasks into structured sequential phases reduces extraneous cognitive load.', + recommendation: 'Format multi-step assignment guidelines into sequenced checklists or distinct milestone stages.' + } + ], + improvedDraft: { + title: 'Improved Rubric Draft (idstack Recommended)', + content: `### Revised Task & Assessment Rubric for: ${title} + +> **Note:** This is a sample evidence-based draft generated in demo mode. Entering an API key in Settings unlocks live audits on any page. + +#### Analytical Task Prompt +Analyze the core case scenario and formulate a structured recommendation addressing: +1. Primary contributing factors identified in the evidence base. +2. Direct trade-offs between proposed intervention strategies. +3. A measurable evaluation plan for validating outcomes. + +#### Evaluation Rubric (Elaborated Criteria) +| Criterion | Exemplary (Proficient) | Developing | Novice | +| :--- | :--- | :--- | :--- | +| **Evidence Application [T1]** | Synthesizes 3+ relevant empirical sources with clear justification. | References 1-2 sources with partial justification. | Makes claims without empirical citations. | +| **Analytical Rigor [T2]** | Rigorously evaluates trade-offs and alternative hypotheses. | Identifies trade-offs but lacks systematic comparison. | Describes facts without evaluating trade-offs. | +| **Actionable Strategy [T1]** | Proposes concrete, measurable milestones with validation metrics. | Proposes general steps without measurable metrics. | Vague or non-actionable suggestions. |` + } + }; +} + module.exports = { cleanJsonResponse, - parseAuditResponse + parseAuditResponse, + getDemoAuditResult }; + diff --git a/extension/background/parser-helper.js b/extension/background/parser-helper.js index f47aa11..a7f0276 100644 --- a/extension/background/parser-helper.js +++ b/extension/background/parser-helper.js @@ -12,3 +12,62 @@ export function cleanJsonResponse(rawText) { export function parseAuditResponse(rawText) { return cleanJsonResponse(rawText); } + +export function getDemoAuditResult(payload = {}) { + const title = (payload && payload.title) ? payload.title : 'Course Material'; + const pageType = (payload && payload.pageType) ? payload.pageType : 'Assignment / Syllabus'; + + return { + summary: { + bloomsLevel: 'Analyze (Level 4)', + alignmentScore: 'Moderate (78%)', + keyTakeaway: `[Demo Mode] Sample audit for "${title}" (${pageType}). Entering an API key in Settings unlocks live audits on any page.` + }, + findings: [ + { + severity: 'warning', + tier: 'T1', + citation: '[Assessment-8] Formative Feedback Matrix', + observation: 'Assessment rubric lacks descriptive performance criteria for intermediate mastery levels.', + evidence: 'Wisniewski et al. (2020) [T1 meta-analysis, d=0.48]: Elaborated feedback and rubric transparency significantly boost student self-regulation and achievement.', + recommendation: 'Add concrete performance descriptors and milestone criteria for each grade band instead of generic labels.' + }, + { + severity: 'critical', + tier: 'T2', + citation: '[Alignment-3] Direct Constructive Alignment', + observation: 'Learning objectives target analytical synthesis, but evaluation instruments only test lower-order recall.', + evidence: 'Biggs (1996) & Liou et al. (2023) [T2 controlled trial]: Constructive misalignment between stated objectives and assessment formats leads to superficial learning strategies.', + recommendation: 'Incorporate authentic problem-solving prompts and case analysis rather than purely multiple-choice recall questions.' + }, + { + severity: 'info', + tier: 'T1', + citation: '[Cognitive-2] Cognitive Load & Chunking', + observation: 'Task instructions present multiple complex requirements in a single unsegmented block.', + evidence: 'Costley et al. (2023) [T1 meta-analysis]: Segmenting complex instructional tasks into structured sequential phases reduces extraneous cognitive load.', + recommendation: 'Format multi-step assignment guidelines into sequenced checklists or distinct milestone stages.' + } + ], + improvedDraft: { + title: 'Improved Rubric Draft (idstack Recommended)', + content: `### Revised Task & Assessment Rubric for: ${title} + +> **Note:** This is a sample evidence-based draft generated in demo mode. Entering an API key in Settings unlocks live audits on any page. + +#### Analytical Task Prompt +Analyze the core case scenario and formulate a structured recommendation addressing: +1. Primary contributing factors identified in the evidence base. +2. Direct trade-offs between proposed intervention strategies. +3. A measurable evaluation plan for validating outcomes. + +#### Evaluation Rubric (Elaborated Criteria) +| Criterion | Exemplary (Proficient) | Developing | Novice | +| :--- | :--- | :--- | :--- | +| **Evidence Application [T1]** | Synthesizes 3+ relevant empirical sources with clear justification. | References 1-2 sources with partial justification. | Makes claims without empirical citations. | +| **Analytical Rigor [T2]** | Rigorously evaluates trade-offs and alternative hypotheses. | Identifies trade-offs but lacks systematic comparison. | Describes facts without evaluating trade-offs. | +| **Actionable Strategy [T1]** | Proposes concrete, measurable milestones with validation metrics. | Proposes general steps without measurable metrics. | Vague or non-actionable suggestions. |` + } + }; +} + diff --git a/extension/background/service-worker.js b/extension/background/service-worker.js index c28805b..450e282 100644 --- a/extension/background/service-worker.js +++ b/extension/background/service-worker.js @@ -1,6 +1,6 @@ import { buildAuditPrompt } from '../shared/prompts.js'; import { getSettings, saveAuditResult } from '../shared/storage.js'; -import { cleanJsonResponse } from './parser-helper.js'; +import { cleanJsonResponse, getDemoAuditResult } from './parser-helper.js'; // Setup side panel behavior on action click if (typeof chrome !== 'undefined' && chrome.sidePanel && chrome.sidePanel.setPanelBehavior) { @@ -39,21 +39,20 @@ if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.onMessage) (async () => { try { const settings = await getSettings(); - const prompt = buildAuditPrompt(request.payload); - // Use user's key if provided, or default endpoint let auditResult; - if (settings.apiKey) { - auditResult = await callLlmApi(settings.apiKey, prompt); + if (settings && settings.apiKey && settings.apiKey.trim()) { + const prompt = buildAuditPrompt(request.payload); + auditResult = await callLlmApi(settings.apiKey.trim(), prompt); } else { - // Fallback demo mock or proxy endpoint - auditResult = await callLlmApi('YOUR_DEFAULT_API_KEY_OR_PROXY', prompt); + // Graceful demo fallback when no API key is provided + auditResult = getDemoAuditResult(request.payload); } await saveAuditResult({ - url: request.payload.url, - title: request.payload.title, - pageType: request.payload.pageType, + url: request.payload?.url || '', + title: request.payload?.title || 'Current Tab', + pageType: request.payload?.pageType || 'Web Page', result: auditResult }); @@ -67,4 +66,5 @@ if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.onMessage) }); } -export { cleanJsonResponse, callLlmApi }; +export { cleanJsonResponse, getDemoAuditResult, callLlmApi }; + diff --git a/extension/shared/evidence-base.cjs b/extension/shared/evidence-base.cjs index 715754a..0bb8374 100644 --- a/extension/shared/evidence-base.cjs +++ b/extension/shared/evidence-base.cjs @@ -1,9 +1,9 @@ const TIER_METADATA = { - T1: { label: 'Meta-analysis', description: 'Systematic reviews / meta-analyses with large effect sizes', color: '#1d4e89' }, - T2: { label: 'Controlled trial', description: 'Peer-reviewed empirical randomized or quasi-experimental studies', color: '#007791' }, - T3: { label: 'Observational', description: 'Correlational, cohort, or longitudinal learning studies', color: '#588157' }, - T4: { label: 'Case study', description: 'Single-institution or discipline-specific qualitative studies', color: '#c97a22' }, - T5: { label: 'Expert guidance', description: 'Established instructional design frameworks (QM, OLC, Bloom)', color: '#6c757d' } + T1: { label: 'Meta-analysis', description: 'Systematic reviews / meta-analyses with large effect sizes', color: '#2f7a4a' }, + T2: { label: 'Controlled trial', description: 'Peer-reviewed empirical randomized or quasi-experimental studies', color: '#2864a8' }, + T3: { label: 'Observational', description: 'Correlational, cohort, or longitudinal learning studies', color: '#a87726' }, + T4: { label: 'Case study', description: 'Single-institution or discipline-specific qualitative studies', color: '#b35a1f' }, + T5: { label: 'Expert guidance', description: 'Established instructional design frameworks (QM, OLC, Bloom)', color: '#6b6b6b' } }; const EVIDENCE_DOMAINS = [ diff --git a/extension/shared/evidence-base.js b/extension/shared/evidence-base.js index dd5d195..6c01133 100644 --- a/extension/shared/evidence-base.js +++ b/extension/shared/evidence-base.js @@ -1,9 +1,9 @@ export const TIER_METADATA = { - T1: { label: 'Meta-analysis', description: 'Systematic reviews / meta-analyses with large effect sizes', color: '#1d4e89' }, - T2: { label: 'Controlled trial', description: 'Peer-reviewed empirical randomized or quasi-experimental studies', color: '#007791' }, - T3: { label: 'Observational', description: 'Correlational, cohort, or longitudinal learning studies', color: '#588157' }, - T4: { label: 'Case study', description: 'Single-institution or discipline-specific qualitative studies', color: '#c97a22' }, - T5: { label: 'Expert guidance', description: 'Established instructional design frameworks (QM, OLC, Bloom)', color: '#6c757d' } + T1: { label: 'Meta-analysis', description: 'Systematic reviews / meta-analyses with large effect sizes', color: '#2f7a4a' }, + T2: { label: 'Controlled trial', description: 'Peer-reviewed empirical randomized or quasi-experimental studies', color: '#2864a8' }, + T3: { label: 'Observational', description: 'Correlational, cohort, or longitudinal learning studies', color: '#a87726' }, + T4: { label: 'Case study', description: 'Single-institution or discipline-specific qualitative studies', color: '#b35a1f' }, + T5: { label: 'Expert guidance', description: 'Established instructional design frameworks (QM, OLC, Bloom)', color: '#6b6b6b' } }; export const EVIDENCE_DOMAINS = [ diff --git a/extension/sidepanel/renderer-helper.cjs b/extension/sidepanel/renderer-helper.cjs index a7dce14..f114f9a 100644 --- a/extension/sidepanel/renderer-helper.cjs +++ b/extension/sidepanel/renderer-helper.cjs @@ -1,6 +1,17 @@ /** * idstack Side Panel HTML Renderer Helper (CommonJS) */ + +function escapeHtml(str) { + if (str === null || str === undefined) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + function renderAuditHTML(data) { if (!data) return ''; const summary = data.summary || { bloomsLevel: 'N/A', alignmentScore: 'N/A', keyTakeaway: '' }; @@ -8,13 +19,13 @@ function renderAuditHTML(data) { const improvedDraft = data.improvedDraft || { title: 'Improved Draft', content: '' }; const findingsHTML = findings.map(f => { - const tier = f.tier || 'T1'; - const tierClass = tier.toLowerCase(); - const severity = f.severity || 'info'; - const citation = f.citation || ''; - const observation = f.observation || ''; - const evidence = f.evidence || ''; - const recommendation = f.recommendation || ''; + const tier = escapeHtml(f.tier || 'T1'); + const tierClass = escapeHtml((f.tier || 'T1').toLowerCase()); + const severity = escapeHtml(f.severity || 'info'); + const citation = escapeHtml(f.citation || ''); + const observation = escapeHtml(f.observation || ''); + const evidence = escapeHtml(f.evidence || ''); + const recommendation = escapeHtml(f.recommendation || ''); return `
@@ -29,13 +40,19 @@ function renderAuditHTML(data) { `; }).join(''); + const bloomsLevel = escapeHtml(summary.bloomsLevel || 'N/A'); + const alignmentScore = escapeHtml(summary.alignmentScore || 'N/A'); + const keyTakeaway = escapeHtml(summary.keyTakeaway || ''); + const draftTitle = escapeHtml(improvedDraft.title || 'Improved Draft'); + const draftContent = escapeHtml(improvedDraft.content || ''); + return `
- Bloom's: ${summary.bloomsLevel || 'N/A'} - Alignment: ${summary.alignmentScore || 'N/A'} + Bloom's: ${bloomsLevel} + Alignment: ${alignmentScore}
-

${summary.keyTakeaway || ''}

+

${keyTakeaway}

Evidence-Based Findings (${findings.length})

@@ -43,10 +60,10 @@ function renderAuditHTML(data) {
-

${improvedDraft.title || 'Improved Draft'}

+

${draftTitle}

-
${improvedDraft.content || ''}
+
${draftContent}