Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions src/SignsOfAI.UI/Components/TaskEntry.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
@* The front door, organised by what the reader came to do rather than by which engine runs.

The pages behind this already speak the right language — the baseline page's own heading is
literally "Does this look like the person who wrote the others?" — but the navigation hid every
one of them behind the name of its machinery: Analyse, Originality, Baseline, Catalogue, Folder.
A teacher does not arrive wanting a baseline. They arrive with thirty essays and one that felt
wrong, and nothing on the page told them which of the five things was theirs.

Deliberately not a wizard and deliberately not a filter. It routes and it explains; the editor is
still directly below, so the person who came to paste a paragraph loses one short band of page
and nothing else. The current task is shown rather than hidden, because a chooser that omits
where you already are makes the reader wonder what they missed.

The folder card always links, including in a browser where scanning a folder is impossible: that
page states why and points at the download. Hiding the card would leave the browser visitor
believing the product cannot do it at all, which is the lowest-common-denominator answer
HostCapabilities exists to avoid. *@
@inherits LocalizedComponent
@inject IFolderBatch Folders

<nav class="task-entry" aria-label="@L["task.aria"]">
<p class="task-q">@L["task.question"]</p>
<ul class="task-cards">
@foreach (var task in Tasks)
{
var here = task.Key == Current;
<li>
@if (here)
{
<div class="task-card here" aria-current="page">
<span class="task-name">@L[$"task.{task.Key}.name"]</span>
<span class="task-what">@L[$"task.{task.Key}.what"]</span>
<span class="task-here">@L["task.here"]</span>
</div>
}
else
{
<NavLink class="task-card" href="@task.Href">
<span class="task-name">@L[$"task.{task.Key}.name"]</span>
<span class="task-what">@L[$"task.{task.Key}.what"]</span>
@* Said before the click, not after it. The card stays in a browser because
hiding it would leave the visitor thinking the product cannot do this at
all — but sending them to a page that explains why, with no warning here,
is a small bait, and this project cannot afford small baits. *@
@if (task.Key == "folder" && !Folders.IsAvailable)
{
<span class="task-where">@L["task.folder.desktop"]</span>
}
</NavLink>
}
</li>
}
</ul>
</nav>

@code {
/// <summary>Which of the four the reader is already on. Empty on a page that is none of them.</summary>
[Parameter] public string Current { get; set; } = "";

private sealed record Task(string Key, string Href);

// Ordered by how often a teacher's week starts there, not by how the code is arranged: the stack
// of submissions comes before the single text, because the single text is usually the second
// step of the same afternoon.
private static readonly Task[] Tasks =
[
new("text", ""),
new("folder", "batch"),
new("person", "baseline"),
new("overlap", "originality"),
];
}
7 changes: 4 additions & 3 deletions src/SignsOfAI.UI/Layout/MainLayout.razor
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,16 @@
<span class="dot"></span> @L["nav.builtwith"]
</span>
<div class="nav-links">
@* Same order as the cards in TaskEntry, so somebody who learns one finds the other. *@
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">@L["nav.analyze"]</NavLink>
<NavLink class="nav-link" href="originality">@L["nav.originality"]</NavLink>
<NavLink class="nav-link" href="baseline">@L["nav.baseline"]</NavLink>
<NavLink class="nav-link" href="catalog">@L["nav.catalog"]</NavLink>
@* Only where a folder can actually be opened, which is not a browser tab. *@
@if (Folders.IsAvailable)
{
<NavLink class="nav-link" href="batch">@L["nav.batch"]</NavLink>
}
<NavLink class="nav-link" href="baseline">@L["nav.baseline"]</NavLink>
<NavLink class="nav-link" href="originality">@L["nav.originality"]</NavLink>
<NavLink class="nav-link" href="catalog">@L["nav.catalog"]</NavLink>
<a class="nav-link" href="https://github.com/peopleworks/SignsofAI" target="_blank" rel="noopener">GitHub</a>
<LanguageSwitch />
</div>
Expand Down
5 changes: 5 additions & 0 deletions src/SignsOfAI.UI/Pages/Home.razor
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@
<p class="privacy"><Icon Name="lock" /> @L["home.privacy"]</p>
</header>

@* Between the claim and the editor, because the reader who came to paste a paragraph should still
see the box without scrolling, and the one who came with thirty essays should not have to guess
that "Baseline" in the navigation was theirs. *@
<TaskEntry Current="text" />

<section class="editor card">
<div class="toolbar">
<label class="field">
Expand Down
63 changes: 63 additions & 0 deletions src/SignsOfAI.UI/wwwroot/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,69 @@ a { color: var(--brand); }
.tagline strong { color: var(--text); }
.privacy { font-size: .85rem; color: var(--text-muted); margin: 0; }

/* ---- task entry ----
Four routes by what the reader came to do. Quiet on purpose: it sits above the editor, so it must
read as signposting and never compete with the result panels below it. auto-fit rather than a
fixed count, so it becomes two rows on a laptop and one column on a phone without a breakpoint. */
.task-entry { max-width: 900px; margin: 0 auto 1.75rem; }
.task-q {
text-align: center;
font-size: .82rem;
font-weight: 600;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--text-muted);
margin: 0 0 .7rem;
}
.task-cards {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: .7rem;
}
.task-card {
display: flex;
flex-direction: column;
gap: .3rem;
height: 100%;
padding: .85rem 1rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
text-decoration: none;
color: inherit;
transition: border-color .15s, transform .15s;
}
a.task-card:hover, a.task-card:focus-visible {
border-color: var(--brand);
transform: translateY(-1px);
}
.task-name { font-weight: 650; font-size: .96rem; color: var(--text); line-height: 1.3; }
.task-what { font-size: .82rem; color: var(--text-muted); line-height: 1.45; }
/* Where the thing actually lives, when it is not here. Same slot as "you are here", muted. */
.task-where {
margin-top: auto;
padding-top: .3rem;
font-size: .72rem;
font-weight: 600;
letter-spacing: .05em;
text-transform: uppercase;
color: var(--text-muted);
}
/* Where you already are: still readable, visibly not a destination. */
.task-card.here { background: var(--surface-2); border-style: dashed; }
.task-here {
margin-top: auto;
padding-top: .3rem;
font-size: .72rem;
font-weight: 600;
letter-spacing: .05em;
text-transform: uppercase;
color: var(--brand);
}

/* ---- cards ---- */
.card {
background: var(--surface);
Expand Down
22 changes: 17 additions & 5 deletions src/SignsOfAI.UI/wwwroot/i18n/en.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
{
"nav.analyze": "Analyze",
"nav.originality": "Originality",
"nav.batch": "Folder",
"task.aria": "What do you need to do",
"task.question": "What do you need to do?",
"task.here": "you are here",
"task.text.name": "Look at one text",
"task.text.what": "Paste it below. Signals, hidden characters and what it says about its own sources.",
"task.folder.name": "Work through a stack of submissions",
"task.folder.what": "Reads a whole folder and says where to look first. A reading order, not a ranking.",
"task.folder.desktop": "in the desktop app",
"task.person.name": "Ask whether the same person wrote it",
"task.person.what": "Measured against their own earlier work, on their own scale — not against a machine.",
"task.overlap.name": "Ask whether two of them overlap",
"task.overlap.what": "Shows the passages two documents share, highlighted as evidence. You judge.",
"nav.analyze": "One text",
"nav.originality": "Overlap",
"nav.batch": "A folder",
"common.cancel": "Cancel",
"batch.pagetitle": "Scan a folder — Signs of AI Writing",
"batch.h1": "Scan a whole folder",
Expand Down Expand Up @@ -49,7 +61,7 @@
"verdict.signs": "Signs of AI writing",
"verdict.none": "No signs above the measured boundary",
"home.pagetitle": "Signs of AI Writing — detect & de-AI-ify your text",
"home.tagline": "Paste, upload, or just start typing. We flag the tells of AI writing — overused words, rhetorical crutches, robotic rhythm — <strong>live, as you write</strong>, and show you how to fix each one.",
"home.tagline": "We mark the signs of AI writing and show you the evidence, not just a percentage.",
"home.privacy": "Analysis runs entirely in your browser. Your text never leaves your device.",
"home.textlang": "Text language",
"home.textlang.auto": "Auto-detect",
Expand Down Expand Up @@ -331,7 +343,7 @@
"citations.explain.limits": "What it cannot tell you is whether a well-formed, consistent reference is a real paper. That takes looking it up, which means sending the citation to a service — a separate step, and one that would ask you first. And it only runs at all when the document announces a reference list; guessing where a bibliography starts would produce complaints out of formatting.",
"citations.explain.notproof": "A missing reference is usually a slip, not dishonesty, and it is always the writer’s to explain. Ask them for the source itself.",
"citations.explain.noscore": "None of this affects the score. A score is a judgement you can argue with; whether a name appears in a list is something you can check by looking.",
"nav.baseline": "Baseline",
"nav.baseline": "The same person",
"base.pagetitle": "Compare with earlier work — Signs of AI Writing",
"base.h1": "Does this look like the person who wrote the others?",
"base.tagline": "Every AI detector asks whether a text looks like a <strong>machine</strong>, and that question punishes anyone whose ordinary register is formal. This one asks whether it looks like the <strong>writer</strong> — measured against their own earlier work, on their own scale.",
Expand Down
22 changes: 17 additions & 5 deletions src/SignsOfAI.UI/wwwroot/i18n/es.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
{
"nav.analyze": "Analizar",
"nav.originality": "Originalidad",
"nav.batch": "Carpeta",
"task.aria": "Qué necesitas hacer",
"task.question": "¿Qué necesitas hacer?",
"task.here": "estás aquí",
"task.text.name": "Mirar un texto",
"task.text.what": "Pégalo abajo. Señales, caracteres ocultos y qué dice sobre sus propias fuentes.",
"task.folder.name": "Sacar adelante un montón de entregas",
"task.folder.what": "Lee una carpeta entera y dice por dónde empezar. Un orden de lectura, no un ranking.",
"task.folder.desktop": "en la aplicación de escritorio",
"task.person.name": "Preguntar si lo escribió la misma persona",
"task.person.what": "Medido contra sus propios trabajos anteriores, en su propia escala — no contra una máquina.",
"task.overlap.name": "Preguntar si dos se parecen demasiado",
"task.overlap.what": "Enseña los pasajes que dos documentos comparten, resaltados como evidencia. Tú juzgas.",
"nav.analyze": "Un texto",
"nav.originality": "Coincidencias",
"nav.batch": "Una carpeta",
"common.cancel": "Cancelar",
"batch.pagetitle": "Analizar una carpeta — Señales de escritura IA",
"batch.h1": "Analiza una carpeta entera",
Expand Down Expand Up @@ -49,7 +61,7 @@
"verdict.signs": "Señales de escritura con IA",
"verdict.none": "Sin señales por encima del umbral medido",
"home.pagetitle": "Señales de escritura IA — detecta y humaniza tu texto",
"home.tagline": "Pega, sube un archivo o simplemente empieza a escribir. Marcamos las señales de escritura IA —palabras sobreusadas, muletillas retóricas, ritmo robótico— <strong>en vivo, mientras escribes</strong>, y te mostramos cómo corregir cada una.",
"home.tagline": "Marcamos las señales de escritura con IA y te enseñamos la evidencia, no solo un porcentaje.",
"home.privacy": "El análisis se ejecuta por completo en tu navegador. Tu texto nunca sale de tu dispositivo.",
"home.textlang": "Idioma del texto",
"home.textlang.auto": "Detectar automáticamente",
Expand Down Expand Up @@ -331,7 +343,7 @@
"citations.explain.limits": "Lo que no puede decirle es si una referencia bien formada y coherente corresponde a un artículo real. Eso exige buscarla, o sea enviar la cita a un servicio — un paso aparte, y que le preguntaría antes. Y solo se ejecuta cuando el documento anuncia una lista de referencias; adivinar dónde empieza una bibliografía produciría quejas sacadas del formato.",
"citations.explain.notproof": "Una referencia que falta suele ser un descuido, no deshonestidad, y siempre le toca explicarla a quien escribió. Pídale la fuente en sí.",
"citations.explain.noscore": "Nada de esto afecta a la puntuación. Una puntuación es un juicio discutible; que un apellido esté o no en una lista se comprueba mirando.",
"nav.baseline": "Referencia",
"nav.baseline": "La misma persona",
"base.pagetitle": "Comparar con trabajos anteriores — Señales de escritura con IA",
"base.h1": "¿Se parece a quien escribió los otros?",
"base.tagline": "Todo detector de IA pregunta si un texto se parece a una <strong>máquina</strong>, y esa pregunta castiga a quien escribe formal por costumbre. Esta pregunta si se parece a <strong>la persona</strong> — medido contra sus propios trabajos anteriores, en su propia escala.",
Expand Down
16 changes: 11 additions & 5 deletions tests/SignsOfAI.Core.Tests/LocaleFileTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ .. Enum.GetValues<SignCategory>().Select(c => "cat." + c.ToString().ToLowerInvar
.. Enum.GetValues<Severity>().Select(s => "sev." + s.ToString().ToLowerInvariant()),
"verdict.signs", "verdict.none",
"lang.english", "lang.spanish",
// TaskEntry builds these from its own list, so a plain text search cannot see them and a
// deleted one would render as the raw key on the front door.
.. new[] { "text", "folder", "person", "overlap" }
.SelectMany(t => new[] { $"task.{t}.name", $"task.{t}.what" }),
"task.folder.desktop",
];

[Fact]
Expand Down Expand Up @@ -100,11 +105,12 @@ public void Fallback_locale_defines_every_key_the_app_asks_for()
/// Wordings this project decided it may not use, in any locale, because they are claims about the
/// writer rather than about the tool.
///
/// A blacklist is a blunt instrument and this one is deliberate. The decision was taken in #32 and
/// applied to the report and to the score card; it survived for months in the empty state of the
/// home page — "This reads mostly human — nice work" — and in the rhythm caption, because the test
/// that guards it reads <c>AnalysisResult.Verdict</c> and these are locale data. Translations
/// arrive as community pull requests, so the guard has to live where the data does.
/// A blacklist is a blunt instrument and this one is deliberate. The decision was taken in #32 on
/// 2026-08-05 and applied to the report and the score card; the wording survived it by five days
/// in the empty state of the home page — "This reads mostly human — nice work" — and in the
/// rhythm caption, because the test that guards the decision reads <c>AnalysisResult.Verdict</c>
/// and these are locale data. Translations arrive as community pull requests, so the guard has to
/// live where the data does.
/// </summary>
private static readonly string[] RetiredClaims =
[
Expand Down
Loading