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. +![ProCoderJii homepage with a placement practice sample](docs/images/homepage.png) + +
+Student learning workspace + ![Guest learning workspace before the first session](docs/images/learning-workspace.png) +
+ +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.

-
+
- - -
-
+
{PRACTICE_TOPICS.map((topic, index) => ( -
- +
+ 0{index + 1}

{topic.title}

-

+

{topic.description}

-

+

{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 && ( - <> - +export function DashboardClientLayout({ + children, +}: { + children: React.ReactNode; +}) { + const pathname = usePathname(); + const drawer = useRef(null); + const toggle = useRef(null); - {/* Desktop Sidebar */} -
- -
+ useEffect(() => { + drawer.current?.close(); + }, [pathname]); + useEffect(() => { + const desktop = window.matchMedia("(min-width: 768px)"); + const closeOnDesktop = () => { + if (desktop.matches) drawer.current?.close(); + }; + desktop.addEventListener("change", closeOnDesktop); + return () => desktop.removeEventListener("change", closeOnDesktop); + }, []); - {/* Main Content */} -
-
-
{children}
-
-
+ return ( +
+
+ + +
+ toggle.current?.focus()} + onClick={(event) => { + if (event.target === event.currentTarget) drawer.current?.close(); + }} + className="notes-drawer fixed inset-y-0 left-0 m-0 h-dvh max-h-none w-80 max-w-[90vw] border-r border-border bg-background text-foreground backdrop:bg-black/40" + > + + + +
+ +
+
+
+ {children}
- ); +
+
+ ); } diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index ab33597..1a12c67 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -1,20 +1,15 @@ import { DashboardClientLayout } from "./dashboard-layout-client"; export default function DashboardLayout({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }) { - return ( - <> -