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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ jobs:
- name: Unit tests
run: npm test

- name: Deployment gate tests
run: node --test scripts/*.test.mjs

- name: Production build
run: npm run build

Expand Down
18 changes: 18 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Repository guidance

Read `CONTRIBUTING.md` before making changes. Contributions target `dev`;
production promotions follow `docs/branching-and-releases.md`.

For UI work, follow `design-system/MASTER.md` and reuse the shared brand and
action styles. Keep real student activity distinct from interactive samples.
Verify keyboard navigation and narrow layouts alongside the relevant checks.

<!-- BEGIN:nextjs-agent-rules -->

## This is NOT the Next.js you know

This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.

This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.

<!-- END:nextjs-agent-rules -->
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ Open a pull request from your fork branch to `procoderji-com/procoderji:dev`. Fi
template, link a related issue when one exists, explain the resulting behavior, and list the
checks you ran.
Include screenshots for visible UI changes and sources or permissions for learning content.
For interface changes, follow the [design system](design-system/MASTER.md): use the shared
brand, typography, buttons and colour tokens, and check both keyboard and mobile navigation.

`dev` is the integration/staging branch; `main` is production. Feature and dependency changes
go through `dev`. Maintainers promote the canonical `dev` branch to `main` after staging checks;
Expand Down
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@

# ProCoderJii Learning Hub

### Practise for campus placements. Understand your answers. Build a study habit.
### For the semester. For what’s next.

A student learning workspace with original practice questions, explanations, and saved progress.
A student learning brand bringing together course resources, placement practice, games,
portfolios, concept videos and community. This repository builds the open-source web platform.

[Website](https://procoderji.com) · [Documentation](docs/README.md) · [Roadmap](https://github.com/procoderji-com/procoderji/issues/22) · [Good first issues](https://github.com/procoderji-com/procoderji/issues?q=is%3Aissue%20is%3Aopen%20label%3A%22good%20first%20issue%22) · [Report a bug](https://github.com/procoderji-com/procoderji/issues/new?template=bug_report.yml) · [Propose a question](https://github.com/procoderji-com/procoderji/issues/new?template=practice_question.yml)

Expand All @@ -21,8 +22,18 @@ A student learning workspace with original practice questions, explanations, and
> [!NOTE]
> This is an early student-product foundation. Four practice topics contain 24 original multiple-choice questions. Practice and browser-saved progress work without login. An optional ASP.NET Core backend provides Google sign-in, account progress, and private portfolio drafts. The portfolio editor, two themes, Markdown import, and public-page support are implemented; public publishing is off by default pending deployment configuration. Billing and checkout are not enabled. Course notes, guides, and job listings are awaiting published content.

![ProCoderJii homepage with a placement practice sample](docs/images/homepage.png)

<details>
<summary>Student learning workspace</summary>

![Guest learning workspace before the first session](docs/images/learning-workspace.png)

</details>

Interface contributions follow the [design system](design-system/MASTER.md), with shared
navigation, self-hosted typography, keyboard access and responsive layouts.

See the [product roadmap](docs/product-roadmap.md) for student validation, content growth, and future monetization work.

## Contents
Expand Down
8 changes: 8 additions & 0 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ is distributed. Preserve applicable package licenses and component notices when
frontend archive or image, and inspect that actual artifact against the upstream terms. The
backend-only container does not bundle the Next.js image-processing dependencies.

## DM Sans

Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts).

The self-hosted font in `public/fonts/dm-sans-variable.ttf` is distributed under the
[SIL Open Font License 1.1](public/fonts/OFL.txt). Its full notice is included alongside
the font. See [the source and usage notes](public/fonts/README.md).

## UI/UX Pro Max

Source: [nextlevelbuilder/ui-ux-pro-max-skill](https://github.com/nextlevelbuilder/ui-ux-pro-max-skill)
Expand Down
44 changes: 44 additions & 0 deletions design-system/MASTER.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# ProCoderJii interface

ProCoderJii is a student learning brand, spanning notes, placement practice, games, videos,
community and portfolios. The interface should feel like a useful study desk, with clear
choices and readable content. It must not imply hiring guarantees or invent social proof.

## Direction

UI/UX Pro Max's Swiss Modernism guidance informs the grid, hierarchy, restrained decoration
and keyboard accessibility. Use an editorial opening on the public site and compact,
functional layouts in the student workspace. The existing gold brand remains an accent.

| Token | Value | Use |
| ------------- | --------- | ------------------------------------------- |
| Paper | `#f8f7f2` | Page background |
| Surface | `#ffffff` | Forms, reading and practice |
| Ink | `#242b25` | Headings and primary text |
| Secondary ink | `#60685d` | Supporting text |
| Forest | `#345443` | Links, primary actions, selected navigation |
| Gold | `#edc76e` | Small highlights and warm-up activities |
| Rule | `#dedfd5` | Quiet dividers and input boundaries |

DM Sans is self-hosted under the SIL Open Font License. Georgia provides occasional editorial
display headings; the workspace keeps sans-serif headings. Body text is at least 16px for
reading and form inputs. Small labels supplement, never replace, visible field labels.

## Patterns

- One clear primary action per section. Prefer content rows and dividers over repeated cards.
- Desktop public navigation is shared across public pages. Mobile navigation has visible
controls, an expanded state, Escape dismissal and focus restoration.
- Learning navigation stays consistent between practice, account and portfolio editing.
- Progress displays real saved values, with useful empty states; homepage samples are labelled
as samples and do not impersonate saved activity.
- Notes keep branch/semester URLs and readable document layouts. Games keep their original
controls and feedback. Portfolio drafts remain private until explicit publication.
- Avoid animated backgrounds, gradient text, glass effects, floating decorations and invented
testimonials, rankings, company affiliations or activity counts.

## Verification

Check 375px, 768px, 1024px and 1440px layouts; keyboard focus and mobile menu; practice feedback;
notes navigation; portfolio preview; reading contrast; reduced motion; and existing browser/SEO
checks. Stage changes through `dev`; production uses the reviewed promotion workflow.
Binary file added docs/images/homepage.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/learning-workspace.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
66 changes: 34 additions & 32 deletions e2e/checks.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,23 @@ await interaction("home: desktop nav has working links", "desktop", async (page)
expectTrue((await page.locator("a[href*='procoderji.xyz']").count()) === 0, "no .xyz links");
});

for (const vp of ["desktop", "mobile"]) {
await interaction("home: sample explains answers without saving a session", vp, async (page) => {
await page.goto(BASE + "/", { waitUntil: "networkidle" });
const sample = page.locator(".sample-question");
const check = sample.getByRole("button", { name: "Check answer" });
expectTrue(await check.isDisabled(), "an answer is required");
await sample.getByRole("radio").first().check();
await check.click();
expectTrue((await sample.locator(".sample-answer").textContent()).length > 30, "show a useful explanation");
await sample.getByRole("radio").nth(1).check();
expectTrue(await sample.locator(".sample-answer").count() === 0, "changing an answer clears outdated feedback");
await page.goto(BASE + "/learn", { waitUntil: "networkidle" });
const sessions = page.locator("section").filter({ has: page.getByRole("heading", { name: "Completed sessions", exact: true }) });
expectTrue(await sessions.getByText("0", { exact: true }).isVisible(), "a sample is not recorded as a practice session");
});
}

await interaction("games: pattern round reaches a result", "desktop", async (page) => {
await page.goto(BASE + "/games/pattern-sprint", { waitUntil: "networkidle" });
await page.getByRole("button", { name: "Start game" }).click();
Expand Down Expand Up @@ -292,6 +309,7 @@ await interaction("dashboard: mobile drawer opens, Escape closes", "mobile", asy
expectTrue(await dlg.getByRole("link", { name: /Browse Notes/ }).isVisible(), "drawer has Browse Notes");
await page.keyboard.press("Escape");
await dlg.waitFor({ state: "hidden" });
expectTrue(await page.getByRole("button", { name: /open navigation menu/i }).evaluate(el => el === document.activeElement), "Escape restores focus to the drawer toggle");
});

await interaction("dashboard: sidebar expands to semester and navigates (desktop)", "desktop", async (page) => {
Expand Down Expand Up @@ -360,40 +378,22 @@ await interaction("legal pages: completed details are indexable", "desktop", asy
expectTrue((await page.locator('link[rel="canonical"]').getAttribute("href")) === "https://procoderji.com/terms", "canonical URL");
});

await interaction("hero headline is never covered by the floating logo (several desktop widths)", "desktop", async (page) => {
// The floating logo tile has pointer-events:none, so elementFromPoint cannot see it: compare geometry instead.
for (const [width, height] of [[768, 900], [1024, 768], [1280, 800], [1366, 768], [1440, 900], [1920, 1080]]) {
await interaction("home: headline and sample stay separate at every breakpoint", "desktop", async (page) => {
for (const [width, height] of [[375, 900], [768, 900], [1024, 768], [1280, 800], [1366, 768], [1440, 900], [1920, 1080]]) {
await page.setViewportSize({ width, height });
await page.goto(BASE + "/", { waitUntil: "networkidle" });
await page.waitForTimeout(1200); // let the entrance animations finish
const overlaps = await page.evaluate(() => {
const h1 = document.querySelector("h1");
const range = document.createRange();
range.selectNodeContents(h1);
const textLines = [...range.getClientRects()].filter((r) => r.width > 0 && r.height > 0);
// Outermost pointer-events:none wrapper around every visible image = decorative overlays such as the logo tile.
const overlays = new Set();
for (const img of document.querySelectorAll("img")) {
if (!img.getClientRects().length) continue;
let el = img;
while (el.parentElement && getComputedStyle(el.parentElement).pointerEvents === "none") el = el.parentElement;
if (el !== img || getComputedStyle(img).pointerEvents === "none") overlays.add(el);
}
const hits = [];
for (const el of overlays) {
const o = el.getBoundingClientRect();
if (o.width === 0 || o.height === 0 || getComputedStyle(el).display === "none") continue;
for (const t of textLines) {
if (o.left < t.right && o.right > t.left && o.top < t.bottom && o.bottom > t.top) hits.push(`${el.tagName}.${String(el.className).slice(0, 50)}`);
}
}
return [...new Set(hits)];
});
expectTrue(overlaps.length === 0, `at ${width}px the headline text is overlapped by: ${overlaps.join(", ")}`);
// Visible layout and fonts are the readiness signal. Responsive link prefetches
// need not become network-idle before we can measure the rendered interface.
await page.goto(BASE + "/", { waitUntil: "domcontentloaded" });
await page.getByRole("heading", { level: 1 }).waitFor({ state: "visible" });
await page.locator(".sample-question").waitFor({ state: "visible" });
await page.evaluate(() => document.fonts.ready);
const heading = await page.getByRole("heading", { level: 1 }).boundingBox();
const sample = await page.locator(".sample-question").boundingBox();
const overlaps = heading.x < sample.x + sample.width && heading.x + heading.width > sample.x && heading.y < sample.y + sample.height && heading.y + heading.height > sample.y;
expectTrue(!overlaps, "headline and question overlap at " + width + "px");
}
});


// Needs the live channel feed, so it only runs when YOUTUBE_CHANNEL_ID is set (otherwise the section is hidden by design).
const videosEnabled = /^UC[A-Za-z0-9_-]{22}$/.test(process.env.YOUTUBE_CHANNEL_ID ?? "");
if (!videosEnabled) console.log("SKIP latest videos responsive checks (set YOUTUBE_CHANNEL_ID to run them)");
Expand Down Expand Up @@ -427,7 +427,7 @@ if (videosEnabled) {

await interaction("privacy page discloses every third-party host the site contacts", "desktop", async (page) => {
// Every external host that these pages request must be named on the Privacy page.
const DISCLOSED_AS = { "lottie.host": "lottie.host", "cdn.jsdelivr.net": "jsdelivr", "i.ytimg.com": "i.ytimg.com" };
const DISCLOSED_AS = { "i.ytimg.com": "i.ytimg.com" };
const base = new URL(BASE);
const hosts = new Set();
page.on("request", (r) => {
Expand All @@ -441,7 +441,8 @@ await interaction("privacy page discloses every third-party host the site contac
await page.waitForTimeout(120);
}
}
expectTrue(hosts.size > 0, "expected the home page to contact at least the animation hosts");
const retiredServices = [...hosts].filter(h => /(^|\.)(lottie\.host|jsdelivr\.net|googleapis\.com|gstatic\.com)$/.test(h));
expectTrue(retiredServices.length === 0, "interface fonts and artwork must stay self-hosted: " + retiredServices.join(", "));
await page.goto(BASE + "/privacy", { waitUntil: "networkidle" });
const text = (await page.locator("main").innerText()).toLowerCase();
const missing = [...hosts].filter((h) => !text.includes((DISCLOSED_AS[h] ?? h).toLowerCase()));
Expand All @@ -459,6 +460,7 @@ if (FULLSTACK) {
await browser.close();

console.log("\n========== FINDINGS ==========");
fs.writeFileSync(path.join(OUT, "findings.json"), JSON.stringify(findings, null, 2) + "\n");
if (!findings.length) console.log("none");
const grouped = {};
for (const f of findings) (grouped[f.kind] ??= []).push(f);
Expand Down
9 changes: 6 additions & 3 deletions e2e/portfolio-checks.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,10 @@ export async function runPortfolioChecks({ browser, base, backendOrigin }) {
const url = new URL(request.url());
if (/^https?:$/.test(url.protocol) && url.origin !== new URL(base).origin) foreignRequests.add(url.origin);
});
const response = await page.goto(base + "/u/" + username, { waitUntil: "networkidle" });
// Next.js prefetch requests can outlive rendering; assert the loaded page's
// status, content and privacy boundaries instead of waiting for network idle.
const response = await page.goto(base + "/u/" + username, { waitUntil: "load" });
await page.evaluate(() => document.fonts.ready);
assert.equal(response?.status(), expectedStatus, `Public ${username} should return ${expectedStatus}`);
if (expectedStatus === 200) {
assert.equal(await page.locator("h1").count(), 1);
Expand All @@ -109,7 +112,7 @@ export async function runPortfolioChecks({ browser, base, backendOrigin }) {
}

async function editor(page) {
const response = await page.goto(base + EDITOR, { waitUntil: "networkidle" });
const response = await page.goto(base + EDITOR, { waitUntil: "load" });
assert.equal(response?.status(), 200);
await page.getByRole("heading", { name: "Portfolio builder", exact: true }).waitFor();
await until(() => page.getByRole("button", { name: "Save draft", exact: true }).isEnabled(), "Authenticated editor did not become ready");
Expand Down Expand Up @@ -403,7 +406,7 @@ export async function runPortfolioChecks({ browser, base, backendOrigin }) {

await run("guest", "guests can preview while private APIs and unpublished pages stay protected", async contexts => {
const context = await browser.newContext({ viewport: { width: 390, height: 844 } }); contexts.push(context);
const page = await context.newPage(); await page.goto(base + EDITOR, { waitUntil: "networkidle" });
const page = await context.newPage(); await page.goto(base + EDITOR, { waitUntil: "load" });
await page.getByRole("heading", { name: "Try the editor, then sign in to save", exact: true }).waitFor();
await page.getByLabel("Name", { exact: true }).fill("Guest preview");
assert.equal(await page.getByRole("button", { name: "Save draft", exact: true }).isDisabled(), true);
Expand Down
Loading