From 4bf2fc426b95888c5d967071ad2740c767db37f8 Mon Sep 17 00:00:00 2001 From: wynnwu <1041852+wynnwu@users.noreply.github.com> Date: Mon, 22 Jun 2026 23:26:50 +0800 Subject: [PATCH 1/3] HTML preview: basic in-app viewer + "View in Browser" for advanced docs HTML summaries previously degraded to a plain-text fallback in the preview pane. They now render styled in a basic, static, private WKWebView (FR-047/048): JavaScript disabled, remote network loads blocked via a scoped WKContentRuleList, no cookies/cache persisted, and link clicks routed to the user's default browser. The preview font controls zoom the render via pageZoom (FR-049). A deterministic, unit-tested HTMLFeatureScanner (FR-050) flags interactive/dynamic constructs (scripts, iframe/embed/object, video/audio, canvas, form controls, inline event handlers) while never flagging plain anchor links, including the stamped YouTube source link. When advanced features are present, a labeled "View in Browser" button appears in the top right of the preview toolbar (FR-051). No new dependency (WebKit is an Apple system framework). 68 tests pass (+11 detector tests); clean release build and bundle. Design lives in specs/003-html-preview-viewer/. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 11 ++ README.md | 7 +- .../Services/HTMLFeatureScanner.swift | 60 +++++++ .../Views/AssetBrowser/HTMLWebView.swift | 129 ++++++++++++++++ .../Views/AssetBrowser/MarkdownPreview.swift | 54 ++++++- .../HTMLFeatureScannerTests.swift | 82 ++++++++++ specs/003-html-preview-viewer/plan.md | 108 +++++++++++++ specs/003-html-preview-viewer/research.md | 89 +++++++++++ specs/003-html-preview-viewer/spec.md | 146 ++++++++++++++++++ specs/003-html-preview-viewer/tasks.md | 48 ++++++ 10 files changed, 726 insertions(+), 8 deletions(-) create mode 100644 Sources/SumbeeKit/Services/HTMLFeatureScanner.swift create mode 100644 Sources/SumbeeKit/Views/AssetBrowser/HTMLWebView.swift create mode 100644 Tests/SumbeeKitTests/HTMLFeatureScannerTests.swift create mode 100644 specs/003-html-preview-viewer/plan.md create mode 100644 specs/003-html-preview-viewer/research.md create mode 100644 specs/003-html-preview-viewer/spec.md create mode 100644 specs/003-html-preview-viewer/tasks.md diff --git a/CHANGELOG.md b/CHANGELOG.md index af36953..2e8babc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to Sumbee are documented here. This project adheres to [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### Added +- **In-app HTML viewer.** HTML summaries now render with their own styling inside the preview pane + instead of falling back to plain text. The viewer is deliberately basic and private: it does not + run the document's JavaScript, blocks remote network loads, persists no cookies or cache, and + opens link clicks in your browser; the preview font controls zoom it. When a summary uses + interactive or dynamic features (scripts, embeds, media, forms), a **View in Browser** button + appears in the top right for the full experience. (FR-047 to FR-052; see + `specs/003-html-preview-viewer/`) + ## [0.2.7] - 2026-06-22 ### Fixed diff --git a/README.md b/README.md index 04306ac..21ec780 100644 --- a/README.md +++ b/README.md @@ -58,8 +58,11 @@ uploads or sees your library. No proprietary format, no lock-in. toolbar; the size sticks across sessions and scales headings proportionally. - **Secure key storage** in the macOS Keychain; summarizing is gated until a valid key is set, and re-gated automatically on an auth failure. -- **Markdown or HTML output**, with an optional shared HTML-styling prompt. The Markdown preview - renders tables and clickable links; drag a summary to Finder, or **space-bar Quick Look** it. +- **Markdown or HTML output**, with an optional shared HTML-styling prompt. Both render in-app: the + Markdown preview renders tables and clickable links, and HTML summaries render with their own + styling in a built-in viewer that stays basic and private (no scripts run, no remote loads, link + clicks open in your browser). Interactive HTML gets a one-click **View in Browser** button. Drag a + summary to Finder, or **space-bar Quick Look** it. - **Geek mode.** Flip it on in the bottom bar to preview the exact prompt and an estimated token count before each summary is sent. - **Model-capability aware:** defaults to the latest Claude model and only sends parameters a given diff --git a/Sources/SumbeeKit/Services/HTMLFeatureScanner.swift b/Sources/SumbeeKit/Services/HTMLFeatureScanner.swift new file mode 100644 index 0000000..b40cd6a --- /dev/null +++ b/Sources/SumbeeKit/Services/HTMLFeatureScanner.swift @@ -0,0 +1,60 @@ +import Foundation + +/// Detects "advanced" constructs in a saved HTML summary that the in-app, static, JavaScript-free +/// preview won't execute (FR-050). When any are present, the UI offers a "View in Browser" escape +/// hatch (FR-051) so the user gets the full experience in their real browser. +/// +/// The scan is intentionally a cheap, case-insensitive text match on the raw document, not a full +/// HTML parse: deciding "is there a `") + XCTAssertTrue(r.hasAdvancedFeatures) + XCTAssertEqual(r.features, ["JavaScript"]) + } + + func testIframeEmbedObjectAreEmbeddedContent() { + XCTAssertEqual(HTMLFeatureScanner.scan("").features, ["Embedded content"]) + XCTAssertEqual(HTMLFeatureScanner.scan("").features, ["Embedded content"]) + XCTAssertEqual(HTMLFeatureScanner.scan("").features, ["Embedded content"]) + } + + func testMediaIsAdvanced() { + XCTAssertEqual(HTMLFeatureScanner.scan("").features, ["Media"]) + XCTAssertEqual(HTMLFeatureScanner.scan("").features, ["Media"]) + } + + func testCanvasIsGraphics() { + XCTAssertEqual(HTMLFeatureScanner.scan("").features, ["Graphics"]) + } + + func testFormControlsAreInteractive() { + XCTAssertEqual(HTMLFeatureScanner.scan("
").features, + ["Interactive controls"]) + XCTAssertEqual(HTMLFeatureScanner.scan("").features, + ["Interactive controls"]) + } + + func testInlineEventHandlerIsScriptedHandler() { + let r = HTMLFeatureScanner.scan("
tap
") + XCTAssertTrue(r.hasAdvancedFeatures) + XCTAssertEqual(r.features, ["Scripted handlers"]) + } + + // "on" inside ordinary attribute values / words must not be mistaken for an event handler. + func testWordContainingOnIsNotAHandler() { + let r = HTMLFeatureScanner.scan("

Companion notes, onboarding.

") + XCTAssertFalse(r.hasAdvancedFeatures) + } + + // Multiple categories: labels are present, deduped, and in stable (declaration) order. + func testMultipleFeaturesAreDedupedAndOrdered() { + let html = "
" + let r = HTMLFeatureScanner.scan(html) + XCTAssertEqual(r.features, ["JavaScript", "Media", "Scripted handlers"]) + } + + func testDetectionIsCaseInsensitive() { + XCTAssertTrue(HTMLFeatureScanner.scan("").hasAdvancedFeatures) + } +} diff --git a/specs/003-html-preview-viewer/plan.md b/specs/003-html-preview-viewer/plan.md new file mode 100644 index 0000000..2ad019f --- /dev/null +++ b/specs/003-html-preview-viewer/plan.md @@ -0,0 +1,108 @@ +# Implementation Plan: In-app HTML preview viewer + +**Spec**: `spec.md` · **Decisions**: `research.md` · **Tasks**: `tasks.md` + +## Summary + +Replace the plain-text HTML fallback in the preview pane with a basic, static, private `WKWebView` +viewer, add a deterministic advanced-feature scan, and surface a top-right **"View in Browser"** +button when advanced features are present. No new dependency (WebKit is an Apple framework). + +## Architecture & touch points + +``` +Sources/SumbeeKit/ + Services/ + HTMLFeatureScanner.swift # NEW pure, testable: scan(html) -> Result{advanced, labels} + Views/AssetBrowser/ + HTMLWebView.swift # NEW NSViewRepresentable around WKWebView (JS off, no remote, + # nonPersistent store, link clicks -> browser, pageZoom) + MarkdownPreview.swift # EDIT PreviewPane: branch .html -> HTMLWebView; toolbar gains a + # conditional top-right "View in Browser" button +Tests/SumbeeKitTests/ + HTMLFeatureScannerTests.swift # NEW detector unit tests incl. anchor-link false-positive guard +``` + +Everything else (AppState, LibraryStore, Asset model, streaming, settings) is untouched. The +viewer reads the file the same way `load()` already does. + +## Component contracts + +### `HTMLFeatureScanner` (Services) + +```swift +public enum HTMLFeatureScanner { + public struct Result: Equatable { + public let hasAdvancedFeatures: Bool + public let features: [String] // friendly labels, deduped, stable order + } + public static func scan(_ html: String) -> Result +} +``` + +- Pure, synchronous, no I/O. Case-insensitive. Anchor `` never contributes. +- `features` drives the button's help/tooltip text; `hasAdvancedFeatures == !features.isEmpty`. + +### `HTMLWebView` (Views) + +```swift +struct HTMLWebView: NSViewRepresentable { // NSViewRepresentable + let html: String + var baseSize: Double // -> pageZoom = clamp(baseSize/16, 0.6...2.0) +} +``` + +- `makeNSView`: build `WKWebViewConfiguration` (JS off, `.nonPersistent()` store), attach the + cached/compiled `WKContentRuleList` blocking `^https?://` (best-effort), set `navigationDelegate`, + `loadHTMLString(html, baseURL: nil)`. +- `updateNSView`: apply `pageZoom`; reload only if `html` changed. +- `Coordinator: WKNavigationDelegate`: allow the first programmatic load; `.linkActivated` → + `NSWorkspace.shared.open(url)` + `.cancel`; any later main-frame navigation → `.cancel`. + +### `PreviewPane` (edits) + +- `load()`: for `.html`, store the **raw** file text (for the web view) and compute + `HTMLFeatureScanner.scan(...)`; for `.markdown`, unchanged. +- `body`: when `asset.format == .html` and not streaming, show `HTMLWebView(html:baseSize:)` inside + the existing scroll area instead of `MarkdownText`. Markdown path unchanged. +- `toolbar`: after the title `Spacer()`, add a labeled "View in Browser" button **only** when + `asset.format == .html && htmlFeatures.hasAdvancedFeatures`. Existing icon buttons remain. +- Font +/- buttons stay enabled for HTML (they now drive `pageZoom`). + +## Security / privacy checklist (maps to FR-048 / US3) + +- [ ] `allowsContentJavaScript = false` +- [ ] `websiteDataStore = .nonPersistent()` +- [ ] content rule list blocks `^https?://` (best-effort, scoped so the local doc never matches) +- [ ] link clicks + stray navigations cancelled; links open via `NSWorkspace` +- [ ] `baseURL: nil` (no implicit relative/remote resolution) + +## Testing + +- **Unit (added)**: `HTMLFeatureScannerTests` - plain styled HTML → not advanced; `