diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fe3e5ca..0489dd5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..db00921
--- /dev/null
+++ b/AGENTS.md
@@ -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.
+
+
+
+## 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.
+
+
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index b803c65..6f6c5b8 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -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;
diff --git a/README.md b/README.md
index 8e1dee3..1ea5608 100644
--- a/README.md
+++ b/README.md
@@ -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)
@@ -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.
+
+
+
+Student learning workspace
+

+
+
+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
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index ed624bc..2e63418 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -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)
diff --git a/design-system/MASTER.md b/design-system/MASTER.md
new file mode 100644
index 0000000..eb08ec4
--- /dev/null
+++ b/design-system/MASTER.md
@@ -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.
diff --git a/docs/images/homepage.png b/docs/images/homepage.png
new file mode 100644
index 0000000..3b67191
Binary files /dev/null and b/docs/images/homepage.png differ
diff --git a/docs/images/learning-workspace.png b/docs/images/learning-workspace.png
index a450fc4..7ec5774 100644
Binary files a/docs/images/learning-workspace.png and b/docs/images/learning-workspace.png differ
diff --git a/e2e/checks.mjs b/e2e/checks.mjs
index 44e9479..4be8357 100644
--- a/e2e/checks.mjs
+++ b/e2e/checks.mjs
@@ -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();
@@ -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) => {
@@ -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)");
@@ -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) => {
@@ -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()));
@@ -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);
diff --git a/e2e/portfolio-checks.mjs b/e2e/portfolio-checks.mjs
index acc9f56..f963e24 100644
--- a/e2e/portfolio-checks.mjs
+++ b/e2e/portfolio-checks.mjs
@@ -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);
@@ -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");
@@ -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);
diff --git a/package-lock.json b/package-lock.json
index 4ab4f51..964d33d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,11 +8,9 @@
"name": "procoderji-notes",
"version": "0.1.0",
"dependencies": {
- "@lottiefiles/dotlottie-react": "^0.21.1",
"@radix-ui/react-slot": "^1.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
- "framer-motion": "^14.10.1",
"lucide-react": "^0.577.0",
"next": "16.4.5",
"next-mdx-remote": "6.0.0",
@@ -1140,32 +1138,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/@lottiefiles/dotlottie-react": {
- "version": "0.21.1",
- "resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-react/-/dotlottie-react-0.21.1.tgz",
- "integrity": "sha512-y5ec2Ipko1TvUfoUmS9zn+g0Ud+oYtUEJS5Xj27edv1kO8ZAowoA2v6DUWNniZ/+A9m5wjEswqPzShOK6+pdAw==",
- "license": "MIT",
- "dependencies": {
- "@lottiefiles/dotlottie-web": "0.82.1"
- },
- "engines": {
- "node": ">=18.17.0",
- "npm": ">=9.5.0"
- },
- "peerDependencies": {
- "react": "^17 || ^18 || ^19"
- }
- },
- "node_modules/@lottiefiles/dotlottie-web": {
- "version": "0.82.1",
- "resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-web/-/dotlottie-web-0.82.1.tgz",
- "integrity": "sha512-hLPdAL0dUeHKvQvXqw1p3FtvzZUHRtBsTMt3FLLgphFThsUPNUj2+U+GyttJ5H5qSt5JreLzoaD+IaKO8H/VnA==",
- "license": "MIT",
- "engines": {
- "node": ">=18.17.0",
- "npm": ">=9.5.0"
- }
- },
"node_modules/@mdx-js/mdx": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.1.tgz",
@@ -4325,33 +4297,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/framer-motion": {
- "version": "14.10.1",
- "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-14.10.1.tgz",
- "integrity": "sha512-hT8WLczAcreOHG4EHymMtATztwUc3al1/88/stal3tpxYkMefGNzIPHHgFFHW3iLnrNY36BrKKJDuT2V7o8Tfw==",
- "license": "MIT",
- "dependencies": {
- "motion-dom": "14.10.1",
- "motion-utils": "14.7.0",
- "tslib": "^2.4.0"
- },
- "peerDependencies": {
- "react": "^18.0.0 || ^19.0.0",
- "react-dom": "^18.0.0 || ^19.0.0",
- "remix": "^3.1.0"
- },
- "peerDependenciesMeta": {
- "react": {
- "optional": true
- },
- "react-dom": {
- "optional": true
- },
- "remix": {
- "optional": true
- }
- }
- },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -6561,21 +6506,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/motion-dom": {
- "version": "14.10.1",
- "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-14.10.1.tgz",
- "integrity": "sha512-NzVzM7y3JJl3rYlVN4yM6kbziab/tHNBEV6gqOoAQIg8npHlIMhW2oPnzr0HkP+rR8xMoiX9+zSZ4BLaVuJadw==",
- "license": "MIT",
- "dependencies": {
- "motion-utils": "14.7.0"
- }
- },
- "node_modules/motion-utils": {
- "version": "14.7.0",
- "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-14.7.0.tgz",
- "integrity": "sha512-woO0/Nb7ys75f94fjvQi/6leiHGvZUYkeHUPEZ3vxsV+9OpEVhkxp01x8MlRwX0zt31EqgXsKDAoBoJLC148Tw==",
- "license": "MIT"
- },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
diff --git a/package.json b/package.json
index 27a1826..eaa0468 100644
--- a/package.json
+++ b/package.json
@@ -16,11 +16,9 @@
"launch-check": "node e2e/launch.mjs"
},
"dependencies": {
- "@lottiefiles/dotlottie-react": "^0.21.1",
"@radix-ui/react-slot": "^1.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
- "framer-motion": "^14.10.1",
"lucide-react": "^0.577.0",
"next": "16.4.5",
"next-mdx-remote": "6.0.0",
diff --git a/public/fonts/OFL.txt b/public/fonts/OFL.txt
new file mode 100644
index 0000000..a0eedfb
--- /dev/null
+++ b/public/fonts/OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/public/fonts/README.md b/public/fonts/README.md
new file mode 100644
index 0000000..4add13f
--- /dev/null
+++ b/public/fonts/README.md
@@ -0,0 +1,10 @@
+# DM Sans
+
+`dm-sans-variable.ttf` is the variable DM Sans font distributed by Google Fonts.
+It is self-hosted with Next.js so reading the application does not request fonts
+from a third-party server.
+
+- Source: https://github.com/google/fonts/tree/main/ofl/dmsans
+- License: [SIL Open Font License 1.1](OFL.txt)
+
+Georgia, where used for editorial headings, is a system-font fallback.
diff --git a/public/fonts/dm-sans-variable.ttf b/public/fonts/dm-sans-variable.ttf
new file mode 100644
index 0000000..c672f98
Binary files /dev/null and b/public/fonts/dm-sans-variable.ttf differ
diff --git a/scripts/deploy-vercel.mjs b/scripts/deploy-vercel.mjs
index f225ab5..d01b622 100644
--- a/scripts/deploy-vercel.mjs
+++ b/scripts/deploy-vercel.mjs
@@ -4,6 +4,7 @@ import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
+import { waitForDeployment } from "./deployment-status.mjs";
const root = path.resolve(import.meta.dirname, "..");
const localStaging = process.argv.includes("--local-staging");
@@ -56,6 +57,28 @@ function authentication(tokenKey) {
return requireValue(tokenKey);
}
+async function confirmReady(result, cwd, project, token, label) {
+ return waitForDeployment(result.deployment?.id, {
+ label,
+ read: async id => {
+ if (!token) {
+ const inspected = vercel(["inspect", id, "--json"], cwd, project);
+ return inspected.deployment ?? inspected;
+ }
+ const endpoint = new URL(`https://api.vercel.com/v13/deployments/${id}`);
+ endpoint.searchParams.set("teamId", requireValue("VERCEL_ORG_ID"));
+ const response = await fetch(endpoint, {
+ headers: { Authorization: `Bearer ${token}` },
+ signal: AbortSignal.timeout(30_000),
+ });
+ if (!response.ok) throw new Error(`${label} status lookup failed (${response.status}).`);
+ const deployment = await response.json();
+ if (deployment.projectId !== project) throw new Error(`${label} deployment belongs to a different project.`);
+ return { id: deployment.id, readyState: deployment.readyState };
+ },
+ });
+}
+
function deploySource() {
const result = spawnSync("git", ["ls-files", "-z"], { cwd: root, encoding: "utf8", windowsHide: true });
if (result.status !== 0) throw new Error("Cannot enumerate tracked deployment source.");
@@ -103,8 +126,9 @@ async function main() {
const frontendSource = deploySource();
fs.copyFileSync(path.join(backendSource, "vercel.backend.json"), path.join(backendSource, "vercel.json"));
const deployArgs = ["deploy", "--prod", "--yes", "--json", "--meta", `sourceCommit=${sha}`, "--meta", `sourceBranch=${branch}`];
- const backend = vercel([...deployArgs, "--project", backendProject], backendSource, backendProject, authentication("VERCEL_BACKEND_TOKEN"));
- if (backend.status === "error" || backend.deployment?.readyState !== "READY") throw new Error("Backend deployment did not become ready.");
+ const backendToken = authentication("VERCEL_BACKEND_TOKEN");
+ const backend = vercel([...deployArgs, "--project", backendProject], backendSource, backendProject, backendToken);
+ await confirmReady(backend, backendSource, backendProject, backendToken, "Backend");
await check(`${backendOrigin}/api/platform/health`, async r => r.ok && (await r.json()).status === "ok");
await check(`${backendOrigin}/api/platform/auth/session`, async r => {
if (!r.ok) return false;
@@ -113,8 +137,9 @@ async function main() {
});
console.log("Backend is ready; database-backed session check passed.");
- const frontend = vercel([...deployArgs, "--project", frontendProject], frontendSource, frontendProject, authentication("VERCEL_FRONTEND_TOKEN"));
- if (frontend.status === "error" || frontend.deployment?.readyState !== "READY") throw new Error("Frontend deployment did not become ready.");
+ const frontendToken = authentication("VERCEL_FRONTEND_TOKEN");
+ const frontend = vercel([...deployArgs, "--project", frontendProject], frontendSource, frontendProject, frontendToken);
+ await confirmReady(frontend, frontendSource, frontendProject, frontendToken, "Frontend");
await check(publicOrigin, async r => r.ok && (branch === "main" || r.headers.get("x-robots-tag")?.includes("noindex")));
console.log(`Frontend is ready: ${publicOrigin}`);
const summary = `### ${branch === "main" ? "Production" : "Staging"} deployment\n\nCommit: \`${sha}\`\n\n- Frontend: ${publicOrigin}\n- Backend: ${backendOrigin}\n- Health and database-backed session checks passed.\n`;
diff --git a/scripts/deployment-status.mjs b/scripts/deployment-status.mjs
new file mode 100644
index 0000000..78e53a9
--- /dev/null
+++ b/scripts/deployment-status.mjs
@@ -0,0 +1,20 @@
+/** Confirm provider state instead of trusting the CLI's deployment snapshot. */
+export async function waitForDeployment(id, {
+ read,
+ label,
+ maxAttempts = 60,
+ intervalMs = 5000,
+ sleep = ms => new Promise(resolve => setTimeout(resolve, ms)),
+}) {
+ if (!/^dpl_[a-zA-Z0-9]+$/.test(id ?? "")) throw new Error(`${label} deployment ID is missing or invalid.`);
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
+ const deployment = await read(id);
+ if (deployment.id !== id) throw new Error(`${label} status returned a different deployment.`);
+ if (deployment.readyState === "READY") return deployment;
+ if (["ERROR", "CANCELED", "CANCELLED"].includes(deployment.readyState)) {
+ throw new Error(`${label} deployment failed (${deployment.readyState}).`);
+ }
+ if (attempt + 1 < maxAttempts) await sleep(intervalMs);
+ }
+ throw new Error(`${label} deployment did not become ready before the timeout.`);
+}
diff --git a/scripts/deployment-status.test.mjs b/scripts/deployment-status.test.mjs
new file mode 100644
index 0000000..5cc4dee
--- /dev/null
+++ b/scripts/deployment-status.test.mjs
@@ -0,0 +1,38 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { waitForDeployment } from "./deployment-status.mjs";
+
+const id = "dpl_test123";
+const options = { label: "Backend", intervalMs: 0, maxAttempts: 3 };
+
+test("queued and building deployments are checked until the provider reports ready", async () => {
+ const states = ["QUEUED", "BUILDING", "READY"];
+ const seen = [];
+ const result = await waitForDeployment(id, { ...options, read: async requested => {
+ seen.push(requested);
+ return { id, readyState: states.shift() };
+ } });
+ assert.equal(result.readyState, "READY");
+ assert.deepEqual(seen, [id, id, id]);
+});
+
+test("failed or canceled builds cannot pass the readiness gate", async () => {
+ for (const readyState of ["ERROR", "CANCELED", "CANCELLED"]) {
+ await assert.rejects(waitForDeployment(id, { ...options, read: async () => ({ id, readyState }) }), /deployment failed/);
+ }
+});
+
+test("a deployment that stays queued fails after bounded polling", async () => {
+ let reads = 0;
+ await assert.rejects(waitForDeployment(id, { ...options, read: async () => {
+ reads++;
+ return { id, readyState: "QUEUED" };
+ } }), /timeout/);
+ assert.equal(reads, options.maxAttempts);
+});
+
+test("missing IDs, mismatched records and unavailable status lookups fail closed", async () => {
+ await assert.rejects(waitForDeployment(undefined, { ...options, read: async () => assert.fail("unexpected lookup") }), /ID is missing/);
+ await assert.rejects(waitForDeployment(id, { ...options, read: async () => ({ id: "dpl_other", readyState: "READY" }) }), /different deployment/);
+ await assert.rejects(waitForDeployment(id, { ...options, read: async () => { throw new Error("Status unavailable"); } }), /Status unavailable/);
+});
diff --git a/src/app/(student)/practice/page.tsx b/src/app/(student)/practice/page.tsx
index e9db4db..6116225 100644
--- a/src/app/(student)/practice/page.tsx
+++ b/src/app/(student)/practice/page.tsx
@@ -14,44 +14,39 @@ export const metadata = pageMetadata({
export default function PracticePage() {
return (
<>
-
+
Prepare with purpose
-
- Campus placement practice
-
-
+
Campus placement practice
+
Work through short topic sessions. Understand why an answer works,
review mistakes, and use your own progress to decide what comes next.
-
+
-
+
{QUESTION_BANK.length} original questions
-
+
Self-paced sessions
-
+
No login required
{QUESTION_BANK.filter((q) => q.topicId === topic.id).length}{" "}
questions · Easy & medium · Explanations included
@@ -65,9 +60,9 @@ export default function PracticePage() {
))}
-
+
Learn from every attempt
-
+
These starter sets build familiarity with common reasoning and
aptitude skills. They are independent learning exercises, and the
small question bank is not a calibrated employer assessment. Use
diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx
index 2027a3c..67f61c9 100644
--- a/src/app/about/page.tsx
+++ b/src/app/about/page.tsx
@@ -1,73 +1,111 @@
import Link from "next/link";
-import { EmailLink, H2, LegalLayout, Operator, P, UL, legalMetadata } from "@/components/legal/legal-layout";
+import {
+ EmailLink,
+ H2,
+ LegalLayout,
+ Operator,
+ P,
+ UL,
+ legalMetadata,
+} from "@/components/legal/legal-layout";
export const metadata = legalMetadata(
- "About",
- "Learn about ProCoderJii, an open-source student platform for placement practice, explanations, free games and saved progress, and how to contribute.",
- "/about"
+ "About",
+ "Learn about ProCoderJii, an open-source student platform for placement practice, explanations, free games and saved progress, and how to contribute.",
+ "/about",
);
export default function AboutPage() {
- return (
-
-
- ProCoderJii is an open-source learning and campus-placement practice project for students.
- It is operated by and grows through reviewed community contributions.
-
+ return (
+
+
+ ProCoderJii is a student learning brand built around concept videos,
+ course resources, placement practice and a community of people learning
+ together. Our YouTube channel, Telegram and WhatsApp communities connect
+ students beyond this website. The open-source platform is operated by{" "}
+ and grows through reviewed community contributions.
+
-
Practise and understand your answers
-
-
- Placement practice covers
- quantitative aptitude, logical reasoning, verbal ability and programming foundations.
- Each session includes correct answers and explanations.
-
-
- Free practice games include
- Pattern Sprint for number patterns and Digit Recall for short-term memory.
-
-
- The learner workspace shows
- question-practice history, topic progress and a suggested next step.
-
-
+
Practise and understand your answers
+
+
+
+ Placement practice
+ {" "}
+ covers quantitative aptitude, logical reasoning, verbal ability and
+ programming foundations. Each session includes correct answers and
+ explanations.
+
+
+
+ Free practice games
+ {" "}
+ include Pattern Sprint for number patterns and Digit Recall for
+ short-term memory.
+
+
+ The{" "}
+
+ learner workspace
+ {" "}
+ shows question-practice history, topic progress and a suggested next
+ step.
+
+
-
Free practice and your progress
-
- Practice is free and works without an account. Question-practice history is saved in this
- browser; clearing site data removes it. Optional Google sign-in can synchronize that
- history when account services are available. Game scores are shown at the end of a round
- and are not yet saved.
-
+
Free practice and your progress
+
+ Practice is free and works without an account. Question-practice history
+ is saved in this browser; clearing site data removes it. Optional Google
+ sign-in can synchronize that history when account services are
+ available. Game scores are shown at the end of a round and are not yet
+ saved.
+
-
Learning content in development
-
- Course, branch and semester navigation is in place. Reviewed course notes, guides and job
- listings are awaiting publication. The current learning exercises are independent practice
- for building skills; they do not predict or guarantee a placement result.
-
+
Learning content in development
+
+ Course, branch and semester navigation is in place. Reviewed course
+ notes, guides and job listings are awaiting publication. The current
+ learning exercises are independent practice for building skills; they do
+ not predict or guarantee a placement result.
+
-
How to contribute
-
- Students and developers can help with original questions, explanations, accessibility,
- documentation and code. Visit the{" "}
-
- GitHub repository and contribution guide
- {" "}
- to find a focused task and the checks it needs.
-
-
-
Learning material is reviewed for correctness and clarity before publication.
-
Where an official syllabus or question paper exists, we link to the university's own page instead of copying it.
-
We do not republish other people's notes, papers or PDFs without permission.
-
+
How to contribute
+
+ Students and developers can help with original questions, explanations,
+ accessibility, documentation and code. Visit the{" "}
+
+ GitHub repository and contribution guide
+ {" "}
+ to find a focused task and the checks it needs.
+
+
+
+ Learning material is reviewed for correctness and clarity before
+ publication.
+
+
+ Where an official syllabus or question paper exists, we link to the
+ university's own page instead of copying it.
+
+
+ We do not republish other people's notes, papers or PDFs without
+ permission.
+
+
-
Corrections and requests
-
- Found a mistake, or want a subject covered? Email or open a reproducible issue
- in the repository. See the Disclaimer{" "}
- for the limits of the learning material.
-
-
- );
+
Corrections and requests
+
+ Found a mistake, or want a subject covered? Email or open
+ a reproducible issue in the repository. See the{" "}
+
+ Disclaimer
+ {" "}
+ for the limits of the learning material.
+
+
+ );
}
diff --git a/src/app/dashboard/dashboard-layout-client.tsx b/src/app/dashboard/dashboard-layout-client.tsx
index d2d382f..a99a604 100644
--- a/src/app/dashboard/dashboard-layout-client.tsx
+++ b/src/app/dashboard/dashboard-layout-client.tsx
@@ -1,94 +1,76 @@
"use client";
-import React, { useEffect, useState } from "react";
+import { useEffect, useRef } from "react";
import { Menu, X } from "lucide-react";
-import { AppSidebar } from "@/components/app-sidebar";
import { usePathname } from "next/navigation";
-import { motion, AnimatePresence } from "framer-motion";
-
-export function DashboardClientLayout({ children }: { children: React.ReactNode }) {
- const pathname = usePathname();
- // Remember which route the drawer was opened on: navigating elsewhere closes it
- // without needing an effect.
- const [openOnPath, setOpenOnPath] = useState(null);
- const isSidebarOpen = openOnPath === pathname;
- const closeSidebar = () => setOpenOnPath(null);
-
- // Escape closes the mobile drawer.
- useEffect(() => {
- if (!isSidebarOpen) return;
- const onKey = (e: KeyboardEvent) => {
- if (e.key === "Escape") setOpenOnPath(null);
- };
- document.addEventListener("keydown", onKey);
- return () => document.removeEventListener("keydown", onKey);
- }, [isSidebarOpen]);
-
- return (
-
- {/* Mobile Header */}
-
-
ProCoderJii
-
-
+import { AppSidebar } from "@/components/app-sidebar";
+import { Brand } from "@/components/brand";
- {/* Mobile Sidebar Overlay */}
-
- {isSidebarOpen && (
- <>
-
-
-
)}
diff --git a/src/app/games/digit-recall/page.tsx b/src/app/games/digit-recall/page.tsx
index 93666fe..6e3fac2 100644
--- a/src/app/games/digit-recall/page.tsx
+++ b/src/app/games/digit-recall/page.tsx
@@ -5,16 +5,28 @@ import { DigitRecall } from "./digit-recall";
export const metadata = pageMetadata({
title: "Digit Recall Game",
- description: "Play Digit Recall for free. Remember a number for a few seconds, type it from memory and build up across five rounds.",
+ description:
+ "Play Digit Recall for free. Remember a number for a few seconds, type it from memory and build up across five rounds.",
path: "/games/digit-recall",
});
export default function DigitRecallPage() {
return (
-
- All games
-
Digit Recall
-
Remember each number for four seconds, then type it from memory. Numbers get longer over five rounds.
+
+
+ All games
+
+
Digit Recall
+
+ Remember each number for four seconds, then type it from memory. Numbers
+ get longer over five rounds.
+
);
diff --git a/src/app/games/layout.tsx b/src/app/games/layout.tsx
index 910ac4a..3dab387 100644
--- a/src/app/games/layout.tsx
+++ b/src/app/games/layout.tsx
@@ -1,25 +1,16 @@
-import Link from "next/link";
-import { ArrowLeft, BookOpen } from "lucide-react";
+import { Navbar } from "@/components/landing/navbar";
+import { Footer } from "@/components/landing/footer";
-export default function GamesLayout({ children }: { children: React.ReactNode }) {
+export default function GamesLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
return (
-
-
-
-
+
+
{children}
-
+
);
}
diff --git a/src/app/games/page.tsx b/src/app/games/page.tsx
index 56028cc..ec4d35a 100644
--- a/src/app/games/page.tsx
+++ b/src/app/games/page.tsx
@@ -4,35 +4,74 @@ import { pageMetadata } from "@/lib/seo";
export const metadata = pageMetadata({
title: "Free Practice Games",
- description: "Play free pattern and memory practice games for students. Short challenges with instant scores and feedback, with no account required.",
+ description:
+ "Play free pattern and memory practice games for students. Short challenges with instant scores and feedback, with no account required.",
path: "/games",
});
const games = [
- { href: "/games/pattern-sprint", icon: Brain, title: "Pattern Sprint", description: "Spot the next number in eight sequences. See the rule after every answer.", detail: "8 questions · 60 seconds" },
- { href: "/games/digit-recall", icon: Hash, title: "Digit Recall", description: "Memorise a number, then type it after it disappears. Each round gets longer.", detail: "5 rounds · self-paced" },
+ {
+ href: "/games/pattern-sprint",
+ icon: Brain,
+ title: "Pattern Sprint",
+ description:
+ "Spot the next number in eight sequences. See the rule after every answer.",
+ detail: "8 questions · 60 seconds",
+ },
+ {
+ href: "/games/digit-recall",
+ icon: Hash,
+ title: "Digit Recall",
+ description:
+ "Memorise a number, then type it after it disappears. Each round gets longer.",
+ detail: "5 rounds · self-paced",
+ },
];
export default function GamesPage() {
return (
-
+
- Free · No login needed
-
Practice Games
-
Take a quick break from reading and challenge your reasoning or memory. Start with a short round and use the feedback to improve.
+ Free · No login needed
+
Practice Games
+
+ Take a quick break from reading and challenge your reasoning or
+ memory. Start with a short round and use the feedback to improve.
+
{games.map((game) => (
-
-
+
+
+
+
{game.title}
-
{game.description}
-
{game.detail}
- Play {game.title}
+
+ {game.description}
+
+
+ {game.detail}
+
+
+ Play {game.title}
+
+
))}
-
These are original practice exercises for skill building, not official company assessments.
+
+ These are original practice exercises for skill building, not official
+ company assessments.
+
);
}
diff --git a/src/app/games/pattern-sprint/page.tsx b/src/app/games/pattern-sprint/page.tsx
index 285caa1..65210d7 100644
--- a/src/app/games/pattern-sprint/page.tsx
+++ b/src/app/games/pattern-sprint/page.tsx
@@ -5,16 +5,28 @@ import { PatternSprint } from "./pattern-sprint";
export const metadata = pageMetadata({
title: "Pattern Sprint Game",
- description: "Play Pattern Sprint for free. Solve eight number sequences against the clock and learn the rule behind every answer.",
+ description:
+ "Play Pattern Sprint for free. Solve eight number sequences against the clock and learn the rule behind every answer.",
path: "/games/pattern-sprint",
});
export default function PatternSprintPage() {
return (
-
- All games
-
Pattern Sprint
-
Find the next number in each sequence. You have 60 seconds for eight questions.
+
+
+ All games
+
+
Pattern Sprint
+
+ Find the next number in each sequence. You have 60 seconds for eight
+ questions.
+
);
diff --git a/src/app/games/pattern-sprint/pattern-sprint.tsx b/src/app/games/pattern-sprint/pattern-sprint.tsx
index 2c7b5a5..126ab87 100644
--- a/src/app/games/pattern-sprint/pattern-sprint.tsx
+++ b/src/app/games/pattern-sprint/pattern-sprint.tsx
@@ -1,7 +1,11 @@
"use client";
import { useEffect, useState } from "react";
-import { PATTERN_QUESTIONS, shuffledQuestions, type PatternQuestion } from "@/lib/games";
+import {
+ PATTERN_QUESTIONS,
+ shuffledQuestions,
+ type PatternQuestion,
+} from "@/lib/games";
type Phase = "ready" | "playing" | "complete";
@@ -54,38 +58,91 @@ export function PatternSprint() {
}
return (
-
+
{phase === "ready" && (
Ready to find the pattern?
-
Pick one answer per sequence. After you answer, you will see the rule. The clock starts when you press Start.
-
+
+ Pick one answer per sequence. After you answer, you will see the
+ rule. The clock starts when you press Start.
+
+
)}
{phase === "playing" && question && (
- Question {index + 1} of {questions.length}
- {seconds}s left
+
+ Question {index + 1} of {questions.length}
+
+
+ {seconds}s left
+
- {children}
+ role="status"
+ className="border-b border-amber-200 bg-amber-50 px-4 py-2 text-center text-sm text-amber-950"
+ >
+ Staging preview. Use sample data; accounts and saved work here are
+ separate from production.
-
-
+ )}
+
{children}
);
}
-
diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx
index 0a22f53..c3dec35 100644
--- a/src/app/not-found.tsx
+++ b/src/app/not-found.tsx
@@ -1,29 +1,47 @@
import Link from "next/link";
+import { Brand } from "@/components/brand";
import type { Metadata } from "next";
export const metadata: Metadata = {
- title: "Page not found",
- robots: { index: false, follow: false },
+ title: "Page not found",
+ robots: { index: false, follow: false },
};
export default function NotFound() {
- return (
-
-
-
Error 404
-
We could not find that page
-
- The link may be old or mistyped. Try browsing the notes by branch and semester.
-
-
-
- Browse notes
-
-
- Go to home
-
-
-
-
- );
+ return (
+
+
+
+
+
+
+ Error 404
+
+
We could not find that page
+
+ The link may be old or mistyped. Try browsing the notes by branch and
+ semester.
+
- The “About” section of the home page displays an animation. The animation file is
- loaded from lottie.host (LottieFiles) and the player's WebAssembly file is loaded from the
- jsDelivr content-delivery network (cdn.jsdelivr.net). When they load, those services receive your
- IP address and browser information, as any website you visit would.{" "}
+ Interface fonts and graphics are served by this site.{" "}
{getYoutubeChannelId() &&
"The home page also shows thumbnails of our latest YouTube videos, which are loaded from YouTube's image servers (i.ytimg.com, operated by Google); that service receives your IP address and browser information when they load. Clicking a video takes you to YouTube, which has its own privacy policy. "}
Links on this site (for example to YouTube or to
diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx
index eb9a2fa..d26b11b 100644
--- a/src/components/app-sidebar.tsx
+++ b/src/components/app-sidebar.tsx
@@ -2,10 +2,15 @@
import * as React from "react";
import Link from "next/link";
-import Image from "next/image";
+import { Brand } from "@/components/brand";
import { usePathname } from "next/navigation";
-import { motion, AnimatePresence } from "framer-motion";
-import { Brain, ChevronRight, GraduationCap, LayoutDashboard, type LucideIcon } from "lucide-react";
+import {
+ Brain,
+ ChevronRight,
+ GraduationCap,
+ LayoutDashboard,
+ type LucideIcon,
+} from "lucide-react";
import { cn } from "@/lib/utils";
import { getCourses } from "@/lib/courses";
import { dashboardHref } from "@/lib/catalog";
@@ -31,16 +36,21 @@ const navigationData: NavItem[] = getCourses().map((course) => ({
}));
function containsPath(items: NavItem[], pathname: string): boolean {
- return items.some((child) => child.href === pathname || (child.items && containsPath(child.items, pathname)));
+ return items.some(
+ (child) =>
+ child.href === pathname ||
+ (child.items && containsPath(child.items, pathname)),
+ );
}
-const SidebarItem = ({ item, depth = 0 }: { item: NavItem; depth?: number }) => {
+const SidebarItem = ({ item }: { item: NavItem }) => {
const pathname = usePathname();
// `null` = follow the current route; once the user toggles, their choice wins.
const [manualOpen, setManualOpen] = React.useState(null);
const hasChildren = !!item.items && item.items.length > 0;
const isActive = item.href ? pathname === item.href : false;
- const isOpen = manualOpen ?? (hasChildren && containsPath(item.items!, pathname));
+ const isOpen =
+ manualOpen ?? (hasChildren && containsPath(item.items!, pathname));
return (