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
82 changes: 82 additions & 0 deletions scripts/test/project-tab-anchors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// One element per anchor on the project page.
//
// The project page used to be a scroll of sections with `id="feedback"`,
// `id="settings"` and so on, linked from a jump-nav. Those sections became TAB
// PANELS, and the panels took ids of their own — so for a while BOTH existed:
// a panel `panel-feedback` and, inside it, a section still carrying the legacy
// `id="feedback"`.
//
// That is not cosmetic. Every panel stays mounted (they hold unsaved drafts),
// so loading /projects/<id>#feedback handed the browser a real element to
// scroll to that lived inside a HIDDEN panel — native anchor behaviour racing
// the tab logic over the same name. ControlInbox and FeedbackItemRow both link
// that way, so it is the common path, not an edge case.
//
// The rule this pins: a tab id may exist exactly once in the DOM, on the panel.
// No section inside a panel may re-declare it.
// Run: npx tsx scripts/test/project-tab-anchors.ts
import { readFileSync, readdirSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";

const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const PROJECTS_DIR = join(ROOT, "src/components/projects");

let pass = 0;
let fail = 0;
function ok(cond: boolean, label: string) {
if (cond) {
pass++;
} else {
fail++;
console.error(`✗ ${label}`);
}
}

const view = readFileSync(join(PROJECTS_DIR, "ProjectWorkspaceView.tsx"), "utf8");
const tabsSrc = readFileSync(join(PROJECTS_DIR, "ProjectTabs.tsx"), "utf8");

// The tab ids are declared in the ProjectTabs call in ProjectWorkspaceView.
const tabIds = [...view.matchAll(/^\s*id:\s*"([a-z-]+)",$/gm)].map((m) => m[1]);
ok(tabIds.length >= 5, `found the tab ids in ProjectWorkspaceView (got ${tabIds.length})`);
ok(tabIds.includes("feedback"), "feedback is a tab — it is what ControlInbox deep-links to");

// The panel must BE the anchor, so the element the browser scrolls to is the
// element that becomes visible.
ok(
/id=\{tab\.id\}/.test(tabsSrc),
"the tab panel uses id={tab.id} — the panel owns the hash, not a nested section",
);
ok(
!/id=\{`panel-\$\{tab\.id\}`\}/.test(tabsSrc),
"no `panel-` prefix: that is what created a second element for the same concept",
);
ok(/aria-controls=\{tab\.id\}/.test(tabsSrc), "aria-controls points at the panel's real id");

// No component rendered inside a panel may re-declare a tab id.
const files = readdirSync(PROJECTS_DIR).filter(
(f) => f.endsWith(".tsx") && f !== "ProjectTabs.tsx",
);
for (const id of tabIds) {
const offenders: string[] = [];
for (const f of files) {
const src = readFileSync(join(PROJECTS_DIR, f), "utf8");
// Match a literal element id, not a string inside a comment or a template.
if (new RegExp(`\\sid="${id}"`).test(src)) offenders.push(f);
}
ok(
offenders.length === 0,
`no section re-declares id="${id}"${offenders.length ? ` — found in ${offenders.join(", ")}` : ""}`,
);
}

// The deep links that make this matter must still exist and still be bare
// hashes, since that is what the tablist reads on mount.
const inbox = readFileSync(join(ROOT, "src/components/control/ControlInbox.tsx"), "utf8");
ok(
/\/projects\/\$\{[^}]+\}#feedback/.test(inbox),
"ControlInbox still deep-links to #feedback (the reason this invariant exists)",
);

console.log(`${pass} passed, ${fail} failed`);
process.exit(fail === 0 ? 0 : 1);
2 changes: 1 addition & 1 deletion src/components/projects/ProjectContextEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ export function ProjectContextEditor({
const hasRepo = getProjectLinks(attrs, gitUrl).repo !== null;

return (
<section id="context" className="ui-project-section" aria-labelledby="project-context-title">
<section className="ui-project-section" aria-labelledby="project-context-title">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="flex items-center gap-2">
Expand Down
2 changes: 1 addition & 1 deletion src/components/projects/ProjectFeedbackSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ export function ProjectFeedbackSection({
}
}
return (
<section id="feedback" className="ui-project-section" aria-labelledby="project-feedback-title">
<section className="ui-project-section" aria-labelledby="project-feedback-title">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-baseline gap-2">
<h2 id="project-feedback-title" className="text-lg font-semibold text-text-primary">
Expand Down
2 changes: 1 addition & 1 deletion src/components/projects/ProjectPlanSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export function ProjectPlanSection({
const nextStep = answer(attrs.next_step);

return (
<section id="plan" className="ui-project-section" aria-labelledby="project-plan-title">
<section className="ui-project-section" aria-labelledby="project-plan-title">
<div className="flex items-center gap-2">
<Target className="h-4 w-4 text-accent-text" aria-hidden="true" />
<h2 id="project-plan-title" className="text-lg font-semibold text-text-primary">
Expand Down
2 changes: 1 addition & 1 deletion src/components/projects/ProjectSettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export function ProjectSettingsPanel({
const router = useRouter();

return (
<details id="settings" className="scroll-mt-28 border-y border-border-subtle">
<details className="border-y border-border-subtle">
<summary className="flex min-h-12 cursor-pointer list-none items-center gap-2 text-sm font-medium text-text-secondary transition-colors hover:text-text-primary">
<Settings className="h-4 w-4" aria-hidden="true" /> Project settings
</summary>
Expand Down
15 changes: 12 additions & 3 deletions src/components/projects/ProjectTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ export function ProjectTabs({ tabs, initialId }: { tabs: ProjectTab[]; initialId
role="tab"
type="button"
aria-selected={active === tab.id}
aria-controls={`panel-${tab.id}`}
aria-controls={tab.id}
tabIndex={active === tab.id ? 0 : -1}
onClick={() => select(tab.id)}
onKeyDown={(e) => onKeyDown(e, i)}
Expand Down Expand Up @@ -122,14 +122,23 @@ export function ProjectTabs({ tabs, initialId }: { tabs: ProjectTab[]; initialId
))}
</div>

{/* The panel owns the hash id, and the sections inside it no longer carry
one. Before this, both existed: the panel was `panel-feedback` while
the section inside kept the legacy `id="feedback"` from when these
were scroll anchors. Every panel stays mounted, so loading
/projects/<id>#feedback gave the browser a real element to scroll to
that was inside a HIDDEN panel — native anchor behaviour racing the
tab logic over the same name. One element per anchor removes the race,
and what the browser scrolls to is now what becomes visible.
`scroll-mt-28` clears the sticky tab bar. */}
{tabs.map((tab) => (
<div
key={tab.id}
id={`panel-${tab.id}`}
id={tab.id}
role="tabpanel"
aria-labelledby={`tab-${tab.id}`}
hidden={active !== tab.id}
className="space-y-6"
className="scroll-mt-28 space-y-6"
>
{/* Mounted even while hidden, on purpose. These panels contain forms
with unsaved drafts and lists that poll while work is in flight;
Expand Down
12 changes: 2 additions & 10 deletions src/components/projects/ProjectWorkspaceView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -186,11 +186,7 @@ export function ProjectWorkspaceView({
needed={showKickoff}
/>
)}
<section
id="overview"
className="scroll-mt-28"
aria-labelledby="project-overview-title"
>
<section className="scroll-mt-28" aria-labelledby="project-overview-title">
<h2 id="project-overview-title" className="sr-only">
Overview
</h2>
Expand Down Expand Up @@ -280,11 +276,7 @@ export function ProjectWorkspaceView({
label: "Activity",
content: (
<>
<section
id="activity"
className="ui-project-section"
aria-labelledby="project-activity-title"
>
<section className="ui-project-section" aria-labelledby="project-activity-title">
<h2
id="project-activity-title"
className="mb-4 text-lg font-semibold text-text-primary"
Expand Down
Loading