From 6daa9999d387739c4c562733f8f9a331351095ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Hern=C3=A1ndez?= Date: Mon, 7 Sep 2026 17:13:14 -0400 Subject: [PATCH 1/2] Word task pane: a spike that boots, and reads its own limits correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The most requested idea, and the cheapest thing that answers the expensive question: does the engine run inside Word at all. It does. Blazor WebAssembly boots in a 340px task pane, the shared components render in one narrow column, and the analysis runs there. Verified by driving the published page in a browser at pane width: 92 words of AI-flavoured text scores 90/100, lists 23 named signals, and says No verdict at this length. This text is 92 words. The boundary was measured only on texts of 649 words and longer, so no verdict is given. which is the whole product working, including the part that refuses to answer. This is a third host, not a second product. Every rule, every component and the engine arrive through SignsOfAI.UI, and `HostCapabilities` gets the third entry its own opening comment was written for — "the day a third host appears, the components do not need revisiting". It needed a new runtime key rather than reusing the browser's, because "runs 100% in your browser" is false inside Word the same way it was false inside a WPF window. Why this host is worth more than the others: a task pane IS a browser, so the engine runs on the machine and the document is never uploaded. Every add-in in this category posts your document to an API. The manifest asks for ReadDocument rather than ReadWriteDocument, so Word enforces that this one only reads instead of asking anyone to trust a sentence on a website. Two things the spike found by running, which is what a spike is for: - The published page must reference the fingerprint placeholder. Without it the file name only exists during development and the pane never starts. - The shared stylesheet assumes a page with room. In a pane, anything with a minimum width pushes content off the right edge, where nobody can scroll. Not proved, because it needs Word itself: that Word accepts the manifest, and that Word.run returns the body of a real document. That call is written and has only ever run against nothing. Sideloading takes a few minutes and the README says how — the shared-folder catalogue walkthrough from PowerPointWebViewer still applies, since we solved it there. Deliberately left for later, and named rather than forgotten: boot.js — the 503 retry from #73 and #74 — lives in SignsOfAI.Web/wwwroot and is not used here. A pane that hangs inside Word is worse than a tab that hangs, so it should move into the shared library before this ships. The Pages workflow does not publish /word/ yet either. PowerPoint is a separate question, not a second Host entry. A deck rarely clears 649 words, so it would mostly withhold the verdict — correctly. Its honest pitch is the character scan and the named tells, which hold at any length: "does this deck carry a humanizer's fingerprints", not "is this AI". 432 tests. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015PEbbiYSNPw7jE3LrPNhyF --- SignsOfAI.slnx | 1 + src/SignsOfAI.UI/Services/HostCapabilities.cs | 14 ++ src/SignsOfAI.UI/wwwroot/i18n/en.json | 9 + src/SignsOfAI.UI/wwwroot/i18n/es.json | 9 + src/SignsOfAI.Word/Program.cs | 20 ++ src/SignsOfAI.Word/README.md | 96 +++++++++ src/SignsOfAI.Word/SignsOfAI.Word.csproj | 22 +++ src/SignsOfAI.Word/TaskPane.razor | 183 ++++++++++++++++++ src/SignsOfAI.Word/_Imports.razor | 5 + src/SignsOfAI.Word/manifest.xml | 112 +++++++++++ src/SignsOfAI.Word/wwwroot/css/taskpane.css | 143 ++++++++++++++ src/SignsOfAI.Word/wwwroot/favicon.svg | 14 ++ src/SignsOfAI.Word/wwwroot/index.html | 71 +++++++ .../wwwroot/js/office-bridge.js | 56 ++++++ 14 files changed, 755 insertions(+) create mode 100644 src/SignsOfAI.Word/Program.cs create mode 100644 src/SignsOfAI.Word/README.md create mode 100644 src/SignsOfAI.Word/SignsOfAI.Word.csproj create mode 100644 src/SignsOfAI.Word/TaskPane.razor create mode 100644 src/SignsOfAI.Word/_Imports.razor create mode 100644 src/SignsOfAI.Word/manifest.xml create mode 100644 src/SignsOfAI.Word/wwwroot/css/taskpane.css create mode 100644 src/SignsOfAI.Word/wwwroot/favicon.svg create mode 100644 src/SignsOfAI.Word/wwwroot/index.html create mode 100644 src/SignsOfAI.Word/wwwroot/js/office-bridge.js diff --git a/SignsOfAI.slnx b/SignsOfAI.slnx index a731827..77c532a 100644 --- a/SignsOfAI.slnx +++ b/SignsOfAI.slnx @@ -15,6 +15,7 @@ + diff --git a/src/SignsOfAI.UI/Services/HostCapabilities.cs b/src/SignsOfAI.UI/Services/HostCapabilities.cs index 6d54202..fbe22e4 100644 --- a/src/SignsOfAI.UI/Services/HostCapabilities.cs +++ b/src/SignsOfAI.UI/Services/HostCapabilities.cs @@ -51,4 +51,18 @@ public sealed class HostCapabilities RuntimeKey = "footer.runtime.desktop", Version = version, }; + + /// + /// A Word task pane. The third host, and the one this class's opening comment was written for. + /// + /// It is a browser in every way that matters here — an embedded WebView, sandboxed, unable to + /// reach a plain-HTTP port on the machine — so the capabilities match the tab. What differs is + /// only what it may say about itself: "runs 100% in your browser" is false inside Word, the + /// same way it was false inside a WPF window before the desktop host got its own key. + /// + public static HostCapabilities WordTaskPane { get; } = new() + { + ReachesLocalServices = false, + RuntimeKey = "footer.runtime.word", + }; } diff --git a/src/SignsOfAI.UI/wwwroot/i18n/en.json b/src/SignsOfAI.UI/wwwroot/i18n/en.json index b7d160a..efc14c4 100644 --- a/src/SignsOfAI.UI/wwwroot/i18n/en.json +++ b/src/SignsOfAI.UI/wwwroot/i18n/en.json @@ -75,6 +75,15 @@ "dl.updates.detail": "At most one check a day. No text, no account, no identifier — and nothing is downloaded for you.", "footer.runtime.browser": "built with .NET 10 and Blazor WebAssembly · runs 100% in your browser", "footer.runtime.desktop": "built with .NET 10, Blazor and WebView2 · runs on this machine", + "footer.runtime.word": "built with .NET 10 and Blazor WebAssembly · runs inside Word, on this machine", + "word.starting": "Starting…", + "word.outside": "This pane is open outside Word, so there is no document to read. Paste text to try the engine.", + "word.paste.placeholder": "Paste text here", + "word.analyze": "Analyse", + "word.analyze.document": "Analyse this document", + "word.reading": "Reading the document…", + "word.stats": "{0} words · {1} sentences · {2} signals", + "word.more": "and {0} more", "nav.catalog": "Catalog", "nav.builtwith": "Built with .NET 10 · Blazor", "footer.role": "Microsoft MVP for .NET", diff --git a/src/SignsOfAI.UI/wwwroot/i18n/es.json b/src/SignsOfAI.UI/wwwroot/i18n/es.json index 5bdbda0..824cc41 100644 --- a/src/SignsOfAI.UI/wwwroot/i18n/es.json +++ b/src/SignsOfAI.UI/wwwroot/i18n/es.json @@ -75,6 +75,15 @@ "dl.updates.detail": "Como mucho una comprobación al día. Sin texto, sin cuenta, sin identificador — y no se descarga nada por ti.", "footer.runtime.browser": "hecho con .NET 10 y Blazor WebAssembly · funciona 100% en tu navegador", "footer.runtime.desktop": "hecho con .NET 10, Blazor y WebView2 · funciona en esta máquina", + "footer.runtime.word": "hecho con .NET 10 y Blazor WebAssembly · funciona dentro de Word, en esta máquina", + "word.starting": "Iniciando…", + "word.outside": "Este panel está abierto fuera de Word, así que no hay documento que leer. Pega un texto para probar el motor.", + "word.paste.placeholder": "Pega el texto aquí", + "word.analyze": "Analizar", + "word.analyze.document": "Analizar este documento", + "word.reading": "Leyendo el documento…", + "word.stats": "{0} palabras · {1} frases · {2} señales", + "word.more": "y {0} más", "nav.catalog": "Catálogo", "nav.builtwith": "Hecho con .NET 10 · Blazor", "footer.role": "Microsoft MVP en .NET", diff --git a/src/SignsOfAI.Word/Program.cs b/src/SignsOfAI.Word/Program.cs new file mode 100644 index 0000000..d7929cd --- /dev/null +++ b/src/SignsOfAI.Word/Program.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using Microsoft.Extensions.DependencyInjection; +using SignsOfAI.UI; +using SignsOfAI.UI.Services; + +var builder = WebAssemblyHostBuilder.CreateDefault(args); +builder.RootComponents.Add("#app"); +builder.RootComponents.Add("head::after"); + +// Same registration call as the other two hosts, so a service added for one is present here too. +builder.Services.AddSignsOfAiUi(); + +// Registered after AddSignsOfAiUi so it wins over the browser default. A task pane runs in an +// embedded browser: it is sandboxed exactly as a tab is, and it reaches no local service. +builder.Services.AddSingleton(HostCapabilities.WordTaskPane); + +var host = builder.Build(); +await host.Services.GetRequiredService().EnsureInitializedAsync(); +await host.RunAsync(); diff --git a/src/SignsOfAI.Word/README.md b/src/SignsOfAI.Word/README.md new file mode 100644 index 0000000..fcc29ce --- /dev/null +++ b/src/SignsOfAI.Word/README.md @@ -0,0 +1,96 @@ +# Signs of AI Writing — Word task pane + +**Status: a spike.** It builds, it boots, and it analyses. It has not yet been loaded inside Word by +anybody — that is the one question left, and the section below is how to answer it in a few minutes. + +## What this is + +The third host. Every rule, every component and the engine arrive through `SignsOfAI.UI`, the same +Razor class library the web app and the desktop window render. This project is the task pane shell +and about sixty lines of Office.js glue. + +That matters more here than it does anywhere else the project runs. **A task pane is a browser**, so +the WebAssembly engine runs inside Word, on the machine, and the document is never uploaded. Every +other add-in in this category posts your document to an API. The manifest asks for `ReadDocument` +rather than `ReadWriteDocument`, so Word itself enforces that this add-in only reads — rather than +asking anyone to trust a sentence on a website. + +## What it does with a short document + +The honest thing, and it is worth seeing before deciding what to build next. A 92-word paste scores +90/100 and the pane says: + +> **No verdict at this length.** This text is 92 words. The boundary was measured only on texts of +> 649 words and longer, so no verdict is given — the score is neither evidence that a machine wrote +> this nor evidence that a person did. Everything below is unaffected. + +The named signals, the character scan and the citation cross-check all still appear, because those +carry no threshold. An essay clears 649 words comfortably; this is mostly a note about what a +PowerPoint deck would get, and why a PowerPoint add-in is a different product rather than the same +one with a different manifest. + +## Trying it + +### In a browser, without Word + +The pane detects that Office.js is absent and offers a paste box instead of showing a broken panel. +That is how it was developed and how the screenshots were taken. + +``` +dotnet publish src/SignsOfAI.Word -c Release -o out +cd out/wwwroot && python -m http.server 8731 +``` + +Then open in a window about 340px wide. + +### In Word + +Word will not load a task pane over plain HTTP from localhost without a certificate, so point the +manifest at a deployed copy, or serve the published folder over HTTPS. + +**Word on the web** — the quickest path: + +1. Open a document on +2. **Home** → **Add-ins** → **More Add-ins** → **My Add-ins** → **Upload My Add-in** +3. Choose `manifest.xml` +4. **Home** → **Read the signs** + +**Word for Windows** has no upload button; it reads a shared-folder catalogue. The full walkthrough +lives in `C:\Proyecto\PowerPointWebViewer\README.md`, which solved this once already — the steps are +identical, only the Trust Center list is per-application. + +## What the spike proved, and what it did not + +Proved, by running it: + +- Blazor WebAssembly boots inside a 340px pane and the engine runs there. +- The shared components render in one narrow column — the score, the withheld verdict, the artifact + and citation panels, the findings list, the EN/ES switch. +- Office.js and Blazor coexist: `Office.onReady` fires before the module finishes starting, so the + bridge parks the answer in a promise the .NET side awaits, rather than a callback registered too + late to hear it. +- The absence of Word is a state, not a crash. + +Not proved, because it needs Word: + +- That Word accepts the manifest and shows the ribbon button. +- That `Word.run` returns the body text of a real document — the call is written but has only ever + run against nothing. + +Found while building, and worth keeping: + +- The published page must reference `_framework/blazor.webassembly#[.{fingerprint}].js`. Without the + placeholder the file name only exists during development and the pane never starts. +- The shared stylesheet assumes a page with room. In a pane, anything with a minimum width pushes + content off the right edge, where there is no way to scroll to it. + +## Before this could ship + +- **The boot retry.** `boot.js` — the work from #73 and #74 that survives a transient 503 — lives in + `SignsOfAI.Web/wwwroot` and is not used here. A pane that hangs inside Word is worse than a tab + that hangs, because there is no obvious way to open developer tools. It should move into the + shared library first. +- **Where it is hosted.** The manifest points at `peopleworks.github.io/SignsofAI/word/`, which the + Pages workflow does not publish yet. +- **The Office Store, or sideloading.** The store wants a privacy policy, a support page and review; + sideloading wants none of that and reaches nobody who has not been told about it. diff --git a/src/SignsOfAI.Word/SignsOfAI.Word.csproj b/src/SignsOfAI.Word/SignsOfAI.Word.csproj new file mode 100644 index 0000000..cadea87 --- /dev/null +++ b/src/SignsOfAI.Word/SignsOfAI.Word.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + + + diff --git a/src/SignsOfAI.Word/TaskPane.razor b/src/SignsOfAI.Word/TaskPane.razor new file mode 100644 index 0000000..f43a8ee --- /dev/null +++ b/src/SignsOfAI.Word/TaskPane.razor @@ -0,0 +1,183 @@ +@using SignsOfAI.Core +@using SignsOfAI.Core.Model +@using SignsOfAI.Core.Rules +@using SignsOfAI.UI.Components +@using SignsOfAI.UI.Services +@inherits LocalizedComponent +@inject AiWritingAnalyzer Analyzer +@inject HostCapabilities Host +@inject IJSRuntime JS + +@* + The task pane. Deliberately not the Home page in a narrow column: a pane is about 320px wide and + the web app's hero, tabs and side-by-side rewriter do not survive that. What it shares with the + other hosts is everything that matters — the same engine, the same rule packs, the same verdict + rules, the same components for the facts. + + It reads the document. It never writes to it, and the manifest asks only for ReadDocument. +*@ + +
+ +
+ Signs of AI Writing + +
+ + @if (_state == State.Starting) + { +

@L["word.starting"]

+ } + else if (_state == State.OutsideWord) + { + @* A plain browser tab. Not an error — it is how this gets developed, and saying so beats a + panel that looks broken. *@ +

@L["word.outside"]

+ + + } + else + { + + @if (_documentName is not null) + { +

@_documentName

+ } + } + + @if (_error is not null) + { +

@_error

+ } + + @if (_result is { } r) + { +
+ + @* The verdict, under the same rule every other surface obeys: below the shortest text + the boundary was measured on, there is no verdict — the findings still stand. *@ +
+ @Math.Round(r.OverallScore)/100 +
+

@L.Verdict(r)

+ + @if (!VerdictBands.Measured(r.Statistics.WordCount)) + { +

+ @L.F("home.unmeasured.length", r.Statistics.WordCount, VerdictBands.MinimumWords ?? 0) +

+ } + +

+ @L.F("word.stats", r.Statistics.WordCount, r.Statistics.SentenceCount, r.Findings.Count) +

+ + @* The facts, which carry no threshold and hold at any length. *@ + + + +
    + @foreach (var f in r.Findings.OrderByDescending(f => f.Weight).Take(12)) + { +
  • + @f.RuleId + @f.Message + @if (!string.IsNullOrWhiteSpace(f.Suggestion)) + { + @f.Suggestion + } +
  • + } +
+ @if (r.Findings.Count > 12) + { +

@L.F("word.more", r.Findings.Count - 12)

+ } +
+ } + +
@L[Host.RuntimeKey]
+
+ +@code { + private enum State { Starting, InsideWord, OutsideWord, Reading } + + private State _state = State.Starting; + private AnalysisResult? _result; + private string? _error; + private string? _documentName; + private string _text = string.Empty; + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + + bool insideWord; + try + { + insideWord = await JS.InvokeAsync("signsOfAiWord.isInsideWord"); + } + catch (Exception) + { + // The bridge did not load at all. Treat it as "not in Word" rather than a dead pane. + insideWord = false; + } + + _state = insideWord ? State.InsideWord : State.OutsideWord; + + if (insideWord) + { + _documentName = await JS.InvokeAsync("signsOfAiWord.documentName"); + } + } + + private async Task ReadAndAnalyze() + { + _error = null; + _state = State.Reading; + + try + { + _text = await JS.InvokeAsync("signsOfAiWord.readDocument"); + } + catch (Exception ex) + { + _error = ex.Message; + _state = State.InsideWord; + return; + } + + _state = State.InsideWord; + AnalyzeCurrentText(); + } + + private void AnalyzeCurrentText() + { + if (string.IsNullOrWhiteSpace(_text)) + { + _result = null; + return; + } + + // "auto" so the analysed language is the document's, independent of the pane's own — the + // distinction the web app makes too, and the one a report has to keep straight. + _result = Analyzer.Analyze(_text, "auto"); + } + + // The same mapping the web app uses, not a second scale invented for a narrow column. The + // classes are the shared stylesheet's, so a boundary change reaches this host for free. + private static string ScoreClass(AnalysisResult r) => + VerdictBands.Emphasis(r.OverallScore, r.Language, r.Statistics.WordCount) switch + { + VerdictEmphasis.High => "danger", + VerdictEmphasis.Elevated => "warn", + VerdictEmphasis.Present => "notice", + VerdictEmphasis.Unmeasured => "unmeasured", + _ => "good", + }; +} diff --git a/src/SignsOfAI.Word/_Imports.razor b/src/SignsOfAI.Word/_Imports.razor new file mode 100644 index 0000000..122f834 --- /dev/null +++ b/src/SignsOfAI.Word/_Imports.razor @@ -0,0 +1,5 @@ +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using SignsOfAI.UI +@using SignsOfAI.UI.Components +@using SignsOfAI.UI.Services diff --git a/src/SignsOfAI.Word/manifest.xml b/src/SignsOfAI.Word/manifest.xml new file mode 100644 index 0000000..69b020f --- /dev/null +++ b/src/SignsOfAI.Word/manifest.xml @@ -0,0 +1,112 @@ + + + + + 7f3c1d94-6a2e-4b58-9c11-0d5e8a4b2f60 + 0.1.0.0 + PeopleWorks Services + en-US + + + + + + + + + + + https://peopleworks.github.io + + + + + + + + + + + ReadDocument + + + + + + + + <Description resid="GetStarted.Description"/> + <LearnMoreUrl resid="GetStarted.LearnMoreUrl"/> + </GetStarted> + + <ExtensionPoint xsi:type="PrimaryCommandSurface"> + <OfficeTab id="TabHome"> + <Group id="SignsOfAI.Group"> + <Label resid="Group.Label"/> + <Icon> + <bt:Image size="16" resid="Icon.16"/> + <bt:Image size="32" resid="Icon.32"/> + <bt:Image size="80" resid="Icon.80"/> + </Icon> + <Control xsi:type="Button" id="SignsOfAI.OpenPane"> + <Label resid="Button.Label"/> + <Supertip> + <Title resid="Button.Label"/> + <Description resid="Button.Tooltip"/> + </Supertip> + <Icon> + <bt:Image size="16" resid="Icon.16"/> + <bt:Image size="32" resid="Icon.32"/> + <bt:Image size="80" resid="Icon.80"/> + </Icon> + <Action xsi:type="ShowTaskpane"> + <TaskpaneId>SignsOfAI.Taskpane</TaskpaneId> + <SourceLocation resid="Taskpane.Url"/> + </Action> + </Control> + </Group> + </OfficeTab> + </ExtensionPoint> + </DesktopFormFactor> + </Host> + </Hosts> + + <Resources> + <bt:Images> + <bt:Image id="Icon.16" DefaultValue="https://peopleworks.github.io/SignsofAI/favicon.png"/> + <bt:Image id="Icon.32" DefaultValue="https://peopleworks.github.io/SignsofAI/favicon.png"/> + <bt:Image id="Icon.80" DefaultValue="https://peopleworks.github.io/SignsofAI/icon-192.png"/> + </bt:Images> + <bt:Urls> + <bt:Url id="GetStarted.LearnMoreUrl" DefaultValue="https://peopleworks.github.io/SignsofAI/why.html"/> + <bt:Url id="Taskpane.Url" DefaultValue="https://peopleworks.github.io/SignsofAI/word/index.html"/> + </bt:Urls> + <bt:ShortStrings> + <bt:String id="GetStarted.Title" DefaultValue="Signs of AI Writing is ready"/> + <bt:String id="Group.Label" DefaultValue="Signs of AI"/> + <bt:String id="Button.Label" DefaultValue="Read the signs"/> + </bt:ShortStrings> + <bt:LongStrings> + <bt:String id="GetStarted.Description" DefaultValue="On the Home tab, choose Read the signs to open the pane."/> + <bt:String id="Button.Tooltip" DefaultValue="Analyse this document on your own machine. It is never uploaded."/> + </bt:LongStrings> + </Resources> + </VersionOverrides> +</OfficeApp> diff --git a/src/SignsOfAI.Word/wwwroot/css/taskpane.css b/src/SignsOfAI.Word/wwwroot/css/taskpane.css new file mode 100644 index 0000000..1e9e706 --- /dev/null +++ b/src/SignsOfAI.Word/wwwroot/css/taskpane.css @@ -0,0 +1,143 @@ +/* + A task pane is roughly 320px wide and the user cannot widen it much. Everything here is about + that column: no hero, no side-by-side, nothing that needs horizontal room. + + It loads *after* the shared stylesheet and only overrides layout — colours, the score classes + (good / notice / warn / danger / unmeasured) and the fact panels come from SignsOfAI.UI, so a + boundary change or a palette change reaches this host without being restated. +*/ + +body.taskpane { + margin: 0; + font: 13px/1.5 "Segoe UI", system-ui, -apple-system, sans-serif; + /* The shared stylesheet is written for a page with room. In a pane there is none, and anything + that assumes a minimum width pushes content off the right edge where nobody can scroll to it. */ + min-width: 0; + overflow-x: hidden; +} + +body.taskpane * { max-width: 100%; } + +/* Rule ids, file names and long suggestions are the things that will not wrap on their own. */ +.pane, .pane * { overflow-wrap: anywhere; } + +.pane-booting { + display: grid; + place-items: center; + height: 100vh; +} + +.pane { + width: 100%; + max-width: 100%; + display: flex; + flex-direction: column; + gap: .75rem; + padding: .75rem; + min-height: 100vh; + box-sizing: border-box; +} + +.pane-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: .5rem; + padding-bottom: .5rem; + border-bottom: 1px solid var(--border, #e2e6ea); +} + +.pane-title { font-weight: 650; font-size: .95rem; } + +.pane-note, +.pane-file, +.pane-unmeasured, +.pane-stats { + margin: 0; + font-size: .8rem; + color: var(--muted, #5b6470); +} + +.pane-file { font-style: italic; word-break: break-all; } + +.pane-error { + margin: 0; + padding: .5rem; + border-radius: .35rem; + font-size: .8rem; + background: rgba(240, 85, 111, .12); +} + +.pane-paste { + width: 100%; + box-sizing: border-box; + font: inherit; + padding: .5rem; + border: 1px solid var(--border, #e2e6ea); + border-radius: .35rem; + resize: vertical; +} + +.pane-run { + width: 100%; + padding: .55rem .75rem; + font: inherit; + font-weight: 600; + color: #fff; + background: var(--brand, #2563eb); + border: 0; + border-radius: .35rem; + cursor: pointer; +} + +.pane-run:disabled { opacity: .5; cursor: default; } + +.pane-result { display: flex; flex-direction: column; gap: .6rem; } + +/* The number, big enough to read at a glance and no bigger — the pane has one column to spend. */ +.pane-score { font-size: 2.1rem; font-weight: 800; line-height: 1; } +.pane-score-of { font-size: .9rem; font-weight: 500; opacity: .6; } +.pane-verdict { margin: 0; font-weight: 650; font-size: .95rem; } + +/* Below the measured length there is no verdict, and the reason has to be visible rather than + inferred from a missing line. */ +.pane-unmeasured { + padding: .5rem; + border-left: 3px solid var(--muted, #9aa4b0); + background: rgba(128, 128, 128, .08); +} + +.pane-findings { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: .55rem; +} + +.pane-findings li { + display: flex; + flex-direction: column; + gap: .15rem; + padding: .45rem .5rem; + border-left: 3px solid var(--border, #e2e6ea); + background: rgba(128, 128, 128, .06); +} + +.pane-rule { + font-family: ui-monospace, "Cascadia Code", Consolas, monospace; + font-size: .7rem; + opacity: .65; +} + +.pane-msg { font-size: .8rem; } +.pane-fix { font-size: .78rem; color: var(--muted, #5b6470); } + +.pane-foot { + margin-top: auto; + padding-top: .5rem; + border-top: 1px solid var(--border, #e2e6ea); + font-size: .7rem; + color: var(--muted, #5b6470); +} diff --git a/src/SignsOfAI.Word/wwwroot/favicon.svg b/src/SignsOfAI.Word/wwwroot/favicon.svg new file mode 100644 index 0000000..bd037d2 --- /dev/null +++ b/src/SignsOfAI.Word/wwwroot/favicon.svg @@ -0,0 +1,14 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32" role="img" aria-label="Signs of AI Writing"> + <defs> + <linearGradient id="bg" gradientUnits="userSpaceOnUse" x1="3" y1="3" x2="29" y2="29"> + <stop offset="0" stop-color="#3b82f6"/> + <stop offset="1" stop-color="#2563eb"/> + </linearGradient> + </defs> + <rect width="32" height="32" rx="7" fill="url(#bg)"/> + <!-- pen nib --> + <path d="M16 5 L22 13.5 L16 27 L10 13.5 Z" fill="#ffffff"/> + <!-- slit and breather hole cut back to the badge gradient --> + <rect x="15.25" y="13" width="1.5" height="9.5" rx="0.75" fill="url(#bg)"/> + <circle cx="16" cy="10.4" r="1.3" fill="url(#bg)"/> +</svg> diff --git a/src/SignsOfAI.Word/wwwroot/index.html b/src/SignsOfAI.Word/wwwroot/index.html new file mode 100644 index 0000000..0e4884e --- /dev/null +++ b/src/SignsOfAI.Word/wwwroot/index.html @@ -0,0 +1,71 @@ +<!DOCTYPE html> +<html lang="en"> + +<head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Signs of AI Writing + + + + + + + + + + + + + + + +
+
+ + + + +
+
+ +
+ An unhandled error has occurred. + Reload + 🗙 +
+ + + + + + + diff --git a/src/SignsOfAI.Word/wwwroot/js/office-bridge.js b/src/SignsOfAI.Word/wwwroot/js/office-bridge.js new file mode 100644 index 0000000..7e6b657 --- /dev/null +++ b/src/SignsOfAI.Word/wwwroot/js/office-bridge.js @@ -0,0 +1,56 @@ +// The whole of this add-in's contact with Word. +// +// It reads. It never writes, and the manifest asks only for ReadDocument so Word itself enforces +// that rather than asking anyone to take our word for it. The text it pulls out is handed straight +// to the WebAssembly module running in this same pane; nothing here opens a socket, and there is no +// endpoint in this file to open one to. +// +// Office.js signals readiness once, before Blazor has finished starting, so the result is parked in +// a promise the .NET side awaits instead of a callback it might register too late. + +window.signsOfAiWord = (function () { + "use strict"; + + let readyResolve; + const ready = new Promise((resolve) => { readyResolve = resolve; }); + + // Set by index.html once Office.onReady has fired. Outside Word — a plain browser tab during + // development — Office.js is absent and this resolves to false instead of hanging. + function markReady(insideWord) { + readyResolve(!!insideWord); + } + + async function isInsideWord() { + return await ready; + } + + // The document body as plain text. Word's own extraction, so headers, footers, footnotes and + // comments are excluded — the body is what a reader would call "the essay". + async function readDocument() { + if (!(await ready)) { + throw new Error("not-inside-word"); + } + + return await Word.run(async (context) => { + const body = context.document.body; + body.load("text"); + await context.sync(); + return body.text ?? ""; + }); + } + + // The document's own name, so a report says which file it describes. + async function documentName() { + if (!(await ready)) return null; + try { + const url = Office.context.document.url; + if (!url) return null; + const parts = url.split(/[\\/]/); + return parts[parts.length - 1] || null; + } catch { + return null; // a document that has never been saved has no url, which is not an error + } + } + + return { markReady, isInsideWord, readDocument, documentName }; +})(); From 55cad9a4ba72ab97cf921c486406923ffafe9ba6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Hern=C3=A1ndez?= Date: Mon, 7 Sep 2026 18:41:01 -0400 Subject: [PATCH 2/2] Publish the task pane, so the manifest's URL is not a 404 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sideloading the add-in answered the spike's first question and exposed the gap the spike had named. Word accepts the manifest and puts "Read the signs" on the Home tab; the pane opens. What it then showed was the web app's navigation and "Sorry, the content you are looking for does not exist", with Word warning that the add-in may not load properly. None of that was the add-in. SourceLocation points at peopleworks.github.io/SignsofAI/word/index.html, deploy-pages.yml published only SignsOfAI.Web, and so that URL answered 404 — verified directly. Pages served its SPA fallback, the web app's Blazor router has no route for /word/index.html, and said so correctly. Word's warning was the 404. It was not, as it looked from the outside, anything to do with the machine it ran on. The workflow now publishes this project into /word/ with its own rewritten base href, and fails the deploy when that entry point is missing. A task pane is loaded by URL from inside Word, where a 404 surfaces as "this add-in may not load properly" rather than as a missing page — which is a long way from the failure, so it is worth failing here instead of finding out in Word a second time. The whole deploy was simulated locally before pushing: publish both, rewrite both base hrefs, copy the pane under /word/, serve the result, and load /SignsofAI/word/index.html. It boots there, with the pane's own base href, and renders its own page rather than the web app's. Measured while there: first load is 3.4 MB compressed, of which 2 MB is ICU data. That cannot go away via InvariantGlobalization — it would silently change how Spanish is handled — so trimming ICU to the locales this needs is the next look. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015PEbbiYSNPw7jE3LrPNhyF --- .github/workflows/deploy-pages.yml | 21 ++++++++++++++++ src/SignsOfAI.Word/README.md | 39 ++++++++++++++++++++++++------ 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index cc50a20..853d629 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -30,15 +30,36 @@ jobs: - name: Publish run: dotnet publish src/SignsOfAI.Web -c Release -o publish + # The Word task pane is its own WebAssembly app and ships under /word/. Office loads a task + # pane by URL, so an add-in whose SourceLocation 404s does not fail quietly: Word says "this + # add-in may not load properly" and the pane shows whatever the fallback served. That is + # exactly what happened the first time the manifest was sideloaded, because this step did not + # exist yet and Pages answered the pane's URL with the web app's SPA fallback. + - name: Publish the Word task pane + run: dotnet publish src/SignsOfAI.Word -c Release -o publish-word + # Project sites live at //, so rewrite the base href from "/" to "//". - name: Rewrite base href run: | sed -i 's|||g' publish/wwwroot/index.html + sed -i 's|||g' publish-word/wwwroot/index.html # SPA deep links (e.g. /catalog) 404 on Pages without a fallback — serve index.html as 404. - name: SPA fallback run: cp publish/wwwroot/index.html publish/wwwroot/404.html + - name: Place the task pane under /word/ + run: cp -r publish-word/wwwroot publish/wwwroot/word + + # The pane is loaded by URL from inside Word, where a 404 shows as an add-in error rather than + # a missing page. Fail the deploy here instead of finding out in Word. + - name: The task pane's own entry point exists + run: | + test -f publish/wwwroot/word/index.html + grep -q 'base href="/${{ github.event.repository.name }}/word/"' publish/wwwroot/word/index.html + grep -q '_framework/blazor.webassembly\.' publish/wwwroot/word/index.html + echo "word/index.html is in the artifact, with its own base href and a real bootstrap name." + - uses: actions/upload-pages-artifact@v5 with: path: publish/wwwroot diff --git a/src/SignsOfAI.Word/README.md b/src/SignsOfAI.Word/README.md index fcc29ce..2c82740 100644 --- a/src/SignsOfAI.Word/README.md +++ b/src/SignsOfAI.Word/README.md @@ -1,7 +1,11 @@ # Signs of AI Writing — Word task pane -**Status: a spike.** It builds, it boots, and it analyses. It has not yet been loaded inside Word by -anybody — that is the one question left, and the section below is how to answer it in a few minutes. +**Status: it loads in Word.** Sideloaded into Word on the web on 7 September: the manifest is +accepted, the ribbon shows a **Signs of AI** group with a **Read the signs** button, and the pane +opens. What it showed was the wrong page — see below, it was a 404 and it is fixed. + +Still untested: that `Word.run` returns the body of a real document. That call is written and has +only ever run against nothing. ## What this is @@ -59,6 +63,22 @@ manifest at a deployed copy, or serve the published folder over HTTPS. lives in `C:\Proyecto\PowerPointWebViewer\README.md`, which solved this once already — the steps are identical, only the Trust Center list is per-application. +## The first sideload, and what it found + +The pane came up showing the **web app's** navigation and "Sorry, the content you are looking for +does not exist", and Word warned *"This add-in may not load properly."* + +Neither was a bug in the add-in. `SourceLocation` points at +`https://peopleworks.github.io/SignsofAI/word/index.html`, the Pages workflow published only the web +app, and so that URL answered **404**. Pages then served its SPA fallback — the web app's +`index.html` — whose Blazor router has no route for `/word/index.html` and correctly said Not found. +Word's warning was the 404, not the machine it was running on. + +Fixed in `deploy-pages.yml`, which now publishes this project into `/word/` with its own rewritten +`` and **fails the deploy** if that entry point is missing. A task pane is loaded by URL +from inside Word, where a 404 surfaces as "this add-in may not load properly" rather than as a +missing page, so it is worth failing the deploy instead of finding out in Word a second time. + ## What the spike proved, and what it did not Proved, by running it: @@ -71,11 +91,15 @@ Proved, by running it: late to hear it. - The absence of Word is a state, not a crash. -Not proved, because it needs Word: +Proved by sideloading it: + +- Word accepts the manifest and puts **Read the signs** on the Home tab. +- The pane opens and loads over HTTPS from Pages. + +Not proved yet: -- That Word accepts the manifest and shows the ribbon button. - That `Word.run` returns the body text of a real document — the call is written but has only ever - run against nothing. + run against nothing, because the page that contains it never loaded. Found while building, and worth keeping: @@ -90,7 +114,8 @@ Found while building, and worth keeping: `SignsOfAI.Web/wwwroot` and is not used here. A pane that hangs inside Word is worse than a tab that hangs, because there is no obvious way to open developer tools. It should move into the shared library first. -- **Where it is hosted.** The manifest points at `peopleworks.github.io/SignsofAI/word/`, which the - Pages workflow does not publish yet. +- **First load is about 3.4 MB** compressed — the runtime, and 2 MB of that is ICU data, which + cannot be dropped with `InvariantGlobalization` because it would silently change how Spanish is + handled. Trimming ICU to the locales this actually needs is the obvious next look. - **The Office Store, or sideloading.** The store wants a privacy policy, a support page and review; sideloading wants none of that and reaches nobody who has not been told about it.