Skip to content
Open
36 changes: 26 additions & 10 deletions REPAIRS.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,20 +94,36 @@ one commit.
### The Ora note runs two sentences together on screen

- **Found by:** C3, on `chunk-c3`, while merging `origin/main` (`07834fa`).
- **Not claimed, and deliberately not fixed here.** It is a one-character change
in a line C3 also edits, which is exactly the shape R3 warns about: a shared
defect buried in a feature diff cannot be reviewed or reverted on its own.
- **Left open by C3, deliberately.** It is a one-character change in a line C3
also edits, which is exactly the shape R3 warns about: a shared defect buried
in a feature diff cannot be reviewed or reverted on its own.
- **Claimed by:** `ui-improvements-post-refactor`, at base `98177fc`.
- **Symptom:** in `settings/page.tsx`, `{SETTINGS_SYSTEM_CONTRIBUTES.ora}` is
followed by ` Switching it on sends...` on the same line, and JSX drops that
leading space. The rendered note reads
followed by ` Switching it on sends...` on the same line, and the leading
space is dropped. The rendered note reads
"...you have to switch on.Switching it on sends...", with the DOM showing
`switch on.<!-- -->Switching`. Introduced by S9 (#93); no check reads rendered
copy, so CI is green on it.
- **Scope:** the only `{expr} Text` pair in that file, and the file uses `{" "}`
nowhere, so this is a one-off rather than a pattern.
- **Fix:** `{SETTINGS_SYSTEM_CONTRIBUTES.ora}{" "}` — or move the following word
onto its own line, which is what makes JSX keep the gap. Worth a look at S9's
other screens for the same pair before closing it.
- **Cause — not what the symptom looks like.** "JSX drops a leading space" is
not true, and a session that believes it will go looking for the wrong thing.
JSX keeps the leading space on the first line of a text node; four probe
routes against this app's own toolchain confirmed it. What drops the space is
an **HTML entity elsewhere in the same text node** — here `Ora&apos;s`, two
lines further down. Same paragraph with the entity spelled out as `Oras`
keeps its space; with `&apos;` it loses it. It is an SWC behaviour, and it
needs no newline: a single-line `{X} Ora&apos;s` loses the space too.
- **Only leading whitespace is affected.** A trailing space before an
expression survives the entity — `previous {rangeDays} days.` in
`pages/[id]/page.tsx` renders correctly and is NOT a defect. Recorded because
it is the first thing a sweep turns up.
- **Scope — swept, one site.** Parsing every `.tsx` for a text node that
carries an entity AND begins with a mid-line space next to an expression
returns exactly this one. So a one-off in fact, though not for the reason
first recorded: the file does use `{" "}`, three lines below the defect, which
is why the retention sentence beside it has always rendered correctly.
- **Fix:** `{SETTINGS_SYSTEM_CONTRIBUTES.ora}{" "}` with the sentence moved to
the next line. Verified in the rendered DOM, not just the diff:
`switch on.<!-- --> <!-- -->Switching`.

## Landed

Expand Down
107 changes: 95 additions & 12 deletions src/app/(app)/issues/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,19 @@

import { useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import {
DEFAULT_ISSUE_SORT,
DEFAULT_SORT_DIRECTION,
ISSUE_SORTS,
ISSUE_SORT_LABEL,
parseIssueSort,
parseSortDirection,
reverseDirection,
useIssuesView,
useStore,
type IssueSort,
type SortDirection,
} from "@/components/store";
import { COUNTED_QUEUES, DESTINATION_LABEL, DESTINATION_PATH, QUEUE_LABEL, parseQueue, type Queue } from "@/lib/vocabulary";
import { PageHeader } from "@/components/page-header";
Expand Down Expand Up @@ -54,10 +59,44 @@ import { WATCH_EMPTY } from "@/lib/watch-copy";
* another, and neither is a preference worth storing.
*/

/** Column headers, drawn on the same six tracks as the rows below them. */
const COLUMN_HEADERS = ["State", "Diagnosis", "Scope", "Confidence", "Impact", "Effort"] as const;
/** Which way round the active column currently reads. */
const DIRECTION_GLYPH: Record<SortDirection, string> = { asc: "\u2191", desc: "\u2193" };
const DIRECTION_WORD: Record<SortDirection, string> = { asc: "ascending", desc: "descending" };

function ColumnHeaders() {
/**
* Column headers, drawn on the same six tracks as the rows below them — and the
* sort control for the column each one heads.
*
* The header IS the sort rather than a caption above one:
*
* - The label comes from `ISSUE_SORT_LABEL`, so a column and the Sort menu
* read one map and cannot end up calling the same ordering two things.
* - The first click sorts the column in ITS OWN default direction rather than
* a uniform descending — a triage list wants the lifecycle from `new` and
* effort from the cheapest. The second click reverses it, and the arrow
* says which way it currently reads.
*
* Newest and What changed head no column: there is no date column to head. The
* menu is what keeps them reachable, which is why it stays.
*/
const COLUMN_SORTS = [
"state",
"cause",
"pages",
"confidence",
"impact",
"effort",
] as const satisfies readonly IssueSort[];

function ColumnHeaders({
sort,
direction,
hrefFor,
}: {
sort: IssueSort;
direction: SortDirection;
hrefFor: (next: { sort: IssueSort; dir?: SortDirection }) => string;
}) {
return (
<div
style={{
Expand All @@ -75,11 +114,46 @@ function ColumnHeaders() {
color: "var(--text-muted)",
}}
>
{COLUMN_HEADERS.map((label, index) => (
<span key={label} style={index >= 4 ? NUMERIC_CELL : TRUNCATE_CELL}>
{label}
</span>
))}
{COLUMN_SORTS.map((key, index) => {
const active = key === sort;
const numeric = index >= 4;
// An inactive column opens in its own default; the active one reverses.
const next = active ? reverseDirection(direction) : DEFAULT_SORT_DIRECTION[key];
return (
<Link
key={key}
href={hrefFor({ sort: key, dir: next })}
replace
// Which column is sorted, which way, and what this click will do —
// in words, for a reader who cannot see the arrow.
aria-label={
active
? `Sorted by ${ISSUE_SORT_LABEL[key]}, ${DIRECTION_WORD[direction]}. Sort ${DIRECTION_WORD[next]}.`
: `Sort by ${ISSUE_SORT_LABEL[key]}`
}
style={{
display: "flex",
alignItems: "center",
justifyContent: numeric ? "flex-end" : "flex-start",
gap: 4,
minWidth: 0,
font: "inherit",
letterSpacing: "inherit",
textTransform: "inherit",
textDecoration: active ? "underline" : "none",
textUnderlineOffset: 3,
// Not colour alone — the underline and the arrow carry it too.
color: active ? "var(--text-body)" : "inherit",
}}
>
{/* Numeric columns are right-aligned, so their arrow leads rather
than trails; the label still ends at the column edge. */}
{numeric && active ? <span aria-hidden="true">{DIRECTION_GLYPH[direction]}</span> : null}
<span style={numeric ? NUMERIC_CELL : TRUNCATE_CELL}>{ISSUE_SORT_LABEL[key]}</span>
{!numeric && active ? <span aria-hidden="true">{DIRECTION_GLYPH[direction]}</span> : null}
</Link>
);
})}
</div>
);
}
Expand All @@ -91,21 +165,30 @@ export default function IssuesPage() {

const queue = parseQueue(searchParams.get("queue"));
const sort = parseIssueSort(searchParams.get("sort"));
const view = useIssuesView(queue, sort);
const direction = parseSortDirection(searchParams.get("dir"), sort);
const view = useIssuesView(queue, sort, direction);

// The tail starts folded. Opening it is the one action this list offers, and
// it is not a commitment to anything — see the note on the fold below.
const [tailOpen, setTailOpen] = useState(false);

const linkTo = useMemo(
() => (next: { queue?: Queue; sort?: IssueSort }) => {
() => (next: { queue?: Queue; sort?: IssueSort; dir?: SortDirection }) => {
const params = new URLSearchParams();
params.set("queue", next.queue ?? queue);
const nextSort = next.sort ?? sort;
// Naming a sort without a direction means "start it the way it reads by
// default" — which is how the menu behaves, and how the first click on a
// column behaves. Staying on the current sort keeps the current
// direction, so changing queue does not silently un-reverse the list.
const nextDir = next.dir ?? (nextSort === sort ? direction : DEFAULT_SORT_DIRECTION[nextSort]);
if (nextSort !== DEFAULT_ISSUE_SORT) params.set("sort", nextSort);
// Only a direction that is not the sort's own default reaches the URL, so
// the common link stays short and a reversed one is explicit.
if (nextDir !== DEFAULT_SORT_DIRECTION[nextSort]) params.set("dir", nextDir);
return pathFor(`${DESTINATION_PATH.issues}?${params.toString()}`);
},
[pathFor, queue, sort],
[pathFor, queue, sort, direction],
);

// The same counts the tabs badge, stated in a sentence. One selector behind
Expand Down Expand Up @@ -204,7 +287,7 @@ export default function IssuesPage() {
/>
</div>

<ColumnHeaders />
<ColumnHeaders sort={sort} direction={direction} hrefFor={linkTo} />

<div style={{ borderBottom: "1px solid var(--border-hairline)" }}>
{view.groups.map((group) => (
Expand Down
3 changes: 2 additions & 1 deletion src/app/(app)/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -572,7 +572,8 @@ function ConnectedSystemsGroup({ disabled }: { disabled: boolean }) {
<div style={{ minWidth: 0 }}>
<h3 className="settings-system__name">{EVIDENCE_SOURCE_LABEL.ora}</h3>
<p className="settings-system__note">
{SETTINGS_SYSTEM_CONTRIBUTES.ora} Switching it on sends the live web address of each watched page to
{SETTINGS_SYSTEM_CONTRIBUTES.ora}{" "}
Switching it on sends the live web address of each watched page to
Ora, whose scans are public: the result enters Ora&apos;s directory and anyone can read it. Webflow
staging addresses are never sent.{" "}
{/* Draft, pending legal review. Rendered rather than withheld: a
Expand Down
142 changes: 142 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -2730,3 +2730,145 @@ textarea:focus-visible,
border: 0;
}
}

/* ── The issues list row ────────────────────────────────────────────────── */

/*
* The row is a grid of cells, not one big link, because two of its cells are
* links to somewhere else and a nested anchor is invalid. The case link instead
* stretches its own hit area over the whole row, which keeps the row-sized
* click target without a row-sized announcement.
*/
.issue-row {
position: relative;
border-top: 1px solid var(--border-hairline);
background: var(--surface-card);
color: var(--text-body);
}

/* A member row inside a remediation group. */
.issue-row--nested {
background: var(--surface-page);
}

.issue-row__open {
color: inherit;
text-decoration: none;
}

/* The stretched hit area. Not a second link — the same one, made row-sized. */
.issue-row__open::after {
content: "";
position: absolute;
inset: 0;
}

.issue-row:hover {
background: var(--surface-raised);
}

.issue-row__open:focus-visible {
outline: none;
}

/* The focus ring belongs on the row, because the row is what the link covers. */
.issue-row:has(.issue-row__open:focus-visible) {
outline: 2px solid var(--focus-ring);
outline-offset: -2px;
}

/*
* Above the stretched link, so they keep their own targets. Without the
* stacking context these sit under it and every click opens the case.
*/
.issue-row__page,
.issue-row .info-tip {
position: relative;
z-index: 1;
}

.issue-row__page {
color: inherit;
text-decoration: none;
}

.issue-row__page:hover,
.issue-row__page:focus-visible {
color: var(--action-primary-ink);
text-decoration: underline;
}


/* Keeps the cause labels on one line down the list where a row has no second
layer to show. Same width as the control it stands in for. */
.issue-row__tip-spacer {
flex: 0 0 auto;
width: 18px;
height: 18px;
}

/* ── The information tip ────────────────────────────────────────────────── */

.info-tip {
flex: 0 0 auto;
display: inline-flex;
}

.info-tip__button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text-muted);
line-height: 1;
cursor: pointer;
}

.info-tip__button:hover,
.info-tip__button[aria-expanded="true"] {
background: var(--surface-input);
color: var(--text-body);
}

.info-tip__button:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 1px;
}

.info-tip__panel {
/*
* There is no width token to reach for: this is the app's first tooltip, and
* the only measures the stylesheet had were `68ch` for running prose — three
* times this, and meant for a paragraph that owns its column. 20rem is the
* width at which a sentence or two breaks into a shape you take in at a
* glance rather than read across. Named so the next tip does not pick its
* own number.
*/
--info-tip-max-width: 20rem;

position: fixed;
z-index: 120;
/* As wide as the text needs, and no wider than the measure above. */
width: max-content;
max-width: var(--info-tip-max-width);
padding: 9px 11px;
border: 1px solid var(--border-hairline);
border-radius: 8px;
background: var(--surface-card);
box-shadow: var(--shadow-popover);
color: var(--text-body);
/* The row is a single nowrap line; the panel is prose and has to wrap out of
everything the row set on it. */
white-space: normal;
text-align: left;
text-transform: none;
letter-spacing: normal;
font-size: 12.5px;
font-weight: 400;
line-height: 1.5;
}
Loading
Loading