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/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..2c82740 --- /dev/null +++ b/src/SignsOfAI.Word/README.md @@ -0,0 +1,121 @@ +# Signs of AI Writing — Word task pane + +**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 + +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. + +## 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: + +- 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. + +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.run` returns the body text of a real document — the call is written but has only ever + run against nothing, because the page that contains it never loaded. + +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. +- **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. 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 }; +})();