From a9539e2b40f2ebf64681dc3f4d47cfbb681c8edf Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Sat, 12 Sep 2026 22:34:38 +0530 Subject: [PATCH 01/22] fix(portfolio): clarify dots and expand navigation on load --- cursor.js | 32 +++++--------------------------- docs/design-system.md | 6 +++--- script.js | 6 +++--- scripts/dot-checks.mjs | 28 ++++++++++++++++++---------- scripts/verify.mjs | 22 +++++++++++++++++++++- style.css | 4 ++-- 6 files changed, 52 insertions(+), 46 deletions(-) diff --git a/cursor.js b/cursor.js index 4a9a9f7..1c584cf 100644 --- a/cursor.js +++ b/cursor.js @@ -158,7 +158,7 @@ .map(Number); // Hex colors avoid Canvas's CSS color resolver forcing style calculation. const dark = document.documentElement.dataset.theme === "dark"; - const highlight = dark ? [170, 180, 210, 0.24] : [45, 52, 73, 0.48]; + const highlight = dark ? [170, 180, 210, 0.42] : [45, 52, 73, 0.58]; context.save(); context.beginPath(); context.rect( @@ -177,7 +177,7 @@ pointer && pointer.surface === surface ? Math.max( 0, - 1 - Math.hypot(x - pointer.x, y - pointer.y) / 185, + 1 - Math.hypot(x - pointer.x, y - pointer.y) / 225, ) * strength : 0; const proximity = distance * distance * (3 - 2 * distance); @@ -187,7 +187,7 @@ dots.forEach((points, index) => { if (!points.length) return; const proximity = index / 24; - const radius = 1.15 + proximity * (dark ? 1.2 : 1.85); + const radius = 1.15 + proximity * (dark ? 1.45 : 2.15); context.fillStyle = "#" + channels @@ -205,30 +205,8 @@ } context.fill(); }); - // Multiply two gradients: all four sides and corners dissolve while the - // middle stays at full strength. This is independent of cursor falloff. - const edgeX = Math.min(120, bounds.width * 0.15); - const visibleTop = Math.max(0, bounds.top); - const visibleBottom = Math.min(height, bounds.bottom); - const edgeY = Math.min(100, (visibleBottom - visibleTop) * 0.18); - context.globalCompositeOperation = "destination-in"; - for (const [start, end, fade, vertical] of [ - [bounds.left, bounds.right, edgeX, false], - [visibleTop, visibleBottom, edgeY, true], - ]) { - const gradient = vertical - ? context.createLinearGradient(0, start, 0, end) - : context.createLinearGradient(start, 0, end, 0); - const stop = fade / (end - start); - gradient.addColorStop(0, "#0000"); - gradient.addColorStop(stop * 0.35, "#0003"); - gradient.addColorStop(stop, "#000f"); - gradient.addColorStop(1 - stop, "#000f"); - gradient.addColorStop(1 - stop * 0.35, "#0003"); - gradient.addColorStop(1, "#0000"); - context.fillStyle = gradient; - context.fillRect(0, 0, width, height); - } + // Keep normal dots uniform through the edges. Only pointer proximity + // above blends the highlight back into the static grid. context.globalCompositeOperation = "destination-out"; context.fillStyle = "#000"; context.beginPath(); diff --git a/docs/design-system.md b/docs/design-system.md index 5738f81..6945649 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -9,7 +9,7 @@ The portfolio uses a warm dotted canvas, expressive serif type, translucent past | Canvas | #f5f4f0 | | Ink | #1a1a1a | | Accent | #3b5bdb | -| Grid | 28px dots, rgb(80 80 75 / 0.30), masked beneath content | +| Grid | 28px dots, rgb(80 80 75 / 0.42); dark rgb(200 200 210 / 0.27), masked beneath content | | Display | Instrument Serif, regular and italic | | Body and controls | Instrument Sans, 400–700 | | Handwritten asides | Nanum Pen Script | @@ -33,7 +33,7 @@ Mobile uses top identity/menu controls, one-column collections, smaller type, tw ## Motion -Folder transforms and hover lifts use ease-out and spring-like curves. Pointer movement locally enlarges dots within a soft radius. Word-sized masks protect visible text with one pixel of padding; rounded masks protect painted UI surfaces. Transparent links and layout wrappers do not clear their surrounding area, and hovering controls keeps nearby dots reactive. Two composited edge gradients fade all four sides and corners while retaining the center. Pointer falloff blends dot size and opacity; dark mode uses a quieter base and highlight. Touch and reduced-motion devices retain only static dots; rendering stops when idle. Without JavaScript, the page uses the plain canvas color. Soft shadows replace decorative borders. Mouse focus avoids oversized rings while keyboard focus remains visible. The game starts only on input. Reduced-motion styles suppress decorative movement. +Folder transforms and hover lifts use ease-out and spring-like curves. Pointer movement locally enlarges dots within a soft radius. Word-sized masks protect visible text with one pixel of padding; rounded masks protect painted UI surfaces. Transparent links and layout wrappers do not clear their surrounding area, and hovering controls keeps nearby dots reactive. Normal dots retain uniform opacity through all four edges and corners; only the pointer highlight blends back into the grid over a 225px radius. Pointer falloff blends dot size and opacity; dark mode uses a quieter base and highlight. Touch and reduced-motion devices retain only static dots; rendering stops when idle. Without JavaScript, the page uses the plain canvas color. Soft shadows replace decorative borders. Mouse focus avoids oversized rings while keyboard focus remains visible. The game starts only on input. Reduced-motion styles suppress decorative movement. The Interactions section of Play Lab contains six working demos: shape switching, selectable spring motion, a tilting/turning studio pass, click celebration, an adjustable spinning petal bloom and a tactile ripple pond with three palettes. A keyboard-accessible segmented control shows exactly one panel at a time, with a short entrance transition. The canvas camera pans independently of cards; zooming out exposes usable world space. Dragging and throwing cards preserves momentum, while mobile defaults to a readable list with an optional canvas view. The physics demo integrates a damped spring with drag velocity and three response presets. Celebration has a clear burst and halo. The water demo propagates a height field and refracts a textured bed, then settles; reduced motion produces a still wave pattern. Keyboard, touch and reduced-motion feedback remain available. @@ -58,6 +58,6 @@ The generated SVGs in assets/about and assets/placeholders are explicitly labele Capabilities open the first row when visible, fill a bright green progress ring over four seconds, then cycle forward. Each detail is one concise paragraph with no hover state. Selecting any row closes the previous one and restarts the timer. Offscreen and hidden tabs stop the cycle. Home’s personal cards share one integrated Quick Ask surface; mobile users can swipe through all five cards. The About photo fan lifts, tilts and pins individual photos with pointer, tap and keyboard controls. -Navigation width changes use a short spring-shaped transition. Button presses, tabs, expandable content and cards have restrained motion. Reduced-motion preferences disable animated transitions and momentum without removing control feedback. The compact wallet shares a single white chat surface; messages load for at least two seconds, remain for ten seconds, and close on an outside click. +Desktop navigation starts fully expanded and collapses after 32px of scrolling, while preserving focused navigation links. The menu button reopens it, and mobile keeps its compact initial menu. Navigation width changes use a short spring-shaped transition. Button presses, tabs, expandable content and cards have restrained motion. Reduced-motion preferences disable animated transitions and momentum without removing control feedback. The compact wallet shares a single white chat surface; messages load for at least two seconds, remain for ten seconds, and close on an outside click. Wallet cards are 175px tall, their decorative top starts at 0px, and the wallet margin is 44px auto -20px at every breakpoint. Mobile retains a horizontally scrollable fan. diff --git a/script.js b/script.js index 2586f1d..183443d 100644 --- a/script.js +++ b/script.js @@ -70,7 +70,7 @@ function setMenu(open, restoreFocus = false) { if (nav && menu) { document.documentElement.classList.add("nav-ready"); menu.hidden = false; - setMenu(false); + setMenu(!smallScreen.matches); menu.addEventListener("click", () => setMenu(menu.getAttribute("aria-expanded") !== "true"), ); @@ -97,13 +97,13 @@ if (nav && menu) { }), ); smallScreen.addEventListener("change", () => setMenu(!smallScreen.matches)); - let previousScroll = 0; + let previousScroll = window.scrollY; window.addEventListener( "scroll", () => { if ( !smallScreen.matches && - Math.abs(window.scrollY - previousScroll) > 150 + Math.abs(window.scrollY - previousScroll) > 32 ) { if (!nav.contains(document.activeElement)) setMenu(false); previousScroll = window.scrollY; diff --git a/scripts/dot-checks.mjs b/scripts/dot-checks.mjs index 462ec68..e39aba8 100644 --- a/scripts/dot-checks.mjs +++ b/scripts/dot-checks.mjs @@ -181,13 +181,13 @@ export async function dotChecks(page, load) { ]); const corner = await point(14, 14); assert( - center.max > 0 && - edges.every((edge) => edge.max > 0 && edge.max < center.max), - "All four edges fade while the center keeps its normal opacity", + center.max >= 90 && + edges.every((edge) => edge.max === center.max), + "Normal light dots stay visible at equal opacity through all four edges", ); assert( - corner.max < Math.min(edges[0].max, edges[2].max), - "Corner fade blends both intersecting edges", + corner.max === center.max, + "Normal dots retain their opacity in the corners", ); await test.emulateMedia({ reducedMotion: "no-preference" }); @@ -211,24 +211,32 @@ export async function dotChecks(page, load) { test, centerRect, (sample) => - sample.max > 32 && sample.max < lightActive.max && sample.max <= 64, + sample.max > 90 && sample.max < lightActive.max && sample.max <= 110, ); await test.emulateMedia({ reducedMotion: "reduce" }); const darkNormal = await waitForPixels( test, centerRect, - (sample) => sample.max <= 34 && sample.max < center.max, + (sample) => sample.max >= 55 && sample.max <= 70, ); assert( - darkNormal.max < center.max, - `Dark mode reduces normal dot opacity: ${JSON.stringify({ center, darkNormal })}`, + darkNormal.max >= 55 && darkNormal.max < center.max, + `Dark normal dots remain clearly visible: ${JSON.stringify({ center, darkNormal })}`, ); assert( darkActive.max > darkNormal.max && darkActive.max < lightActive.max && - darkActive.max <= 64, + darkActive.max <= 110, `Dark hover stays visible but substantially quieter than light mode: ${JSON.stringify({ lightActive, darkActive, darkNormal, pointerState })}`, ); + for (const [x, y] of [ + [14, 490], [1414, 490], [714, 14], [714, 994], [14, 14], + ]) + assert.equal( + (await point(x, y)).max, + darkNormal.max, + "Dark normal dots retain equal opacity at edges and corners", + ); return { center, edges, corner, lightActive, darkActive, darkNormal }; } finally { await context.close(); diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 2b0aef6..d0e4284 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -274,6 +274,26 @@ async function accessibility(page, name) { } async function navigation(page) { + await page.setViewportSize({ width: 1440, height: 1000 }); + await load(page, "/"); + assert.equal( + await page.locator(".menu-toggle").getAttribute("aria-expanded"), + "true", + "Desktop navigation starts fully expanded", + ); + assert( + await page.locator('#site-nav a[href="/play-lab"]').isVisible(), + "Full desktop navigation links are visible on load", + ); + await page.evaluate(() => scrollTo({ top: 80, behavior: "instant" })); + await page.waitForFunction( + () => document.querySelector(".menu-toggle").getAttribute("aria-expanded") === "false", + ); + await page.locator(".menu-toggle").click(); + assert( + await page.locator('#site-nav a[href="/play-lab"]').isVisible(), + "Desktop navigation can be reopened after scrolling", + ); await page.setViewportSize({ width: 390, height: 844 }); await load(page, "/"); const toggle = page.locator(".menu-toggle"); @@ -1443,7 +1463,7 @@ try { ], ["cursor highlights and reduced motion", () => cursorDots(page)], [ - "tight dot masks, edge fading, dark hover and compact wallet", + "tight dot masks, uniform edges, dark hover and compact wallet", () => dotChecks(page, load), ], [ diff --git a/style.css b/style.css index 961bfff..51d232f 100644 --- a/style.css +++ b/style.css @@ -30,7 +30,7 @@ --line: rgb(0 0 0 / 0.075); --glass: #fbfaf7; --strong-glass: #fff; - --dot: rgb(80 80 75 / 0.3); + --dot: rgb(80 80 75 / 0.42); --out: cubic-bezier(0.23, 1, 0.32, 1); --spring: cubic-bezier(0.34, 1.4, 0.64, 1); color-scheme: light; @@ -43,7 +43,7 @@ --line: rgb(255 255 255 / 0.1); --glass: #292a31; --strong-glass: #34353d; - --dot: rgb(200 200 210 / 0.13); + --dot: rgb(200 200 210 / 0.27); color-scheme: dark; } * { From e9c7f344b928c5b465cf9bef5926c27d37725fcf Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Sat, 12 Sep 2026 22:37:29 +0530 Subject: [PATCH 02/22] test(portfolio): wait for static dots and update contrast expectation --- scripts/dot-checks.mjs | 5 ++++- scripts/refinement-checks.mjs | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/dot-checks.mjs b/scripts/dot-checks.mjs index e39aba8..689c59f 100644 --- a/scripts/dot-checks.mjs +++ b/scripts/dot-checks.mjs @@ -214,10 +214,13 @@ export async function dotChecks(page, load) { sample.max > 90 && sample.max < lightActive.max && sample.max <= 110, ); await test.emulateMedia({ reducedMotion: "reduce" }); + // The far corner is outside the hover radius. Wait until the center + // matches it so the reduced-motion media change has reached the canvas. + const darkCorner = await point(14, 14); const darkNormal = await waitForPixels( test, centerRect, - (sample) => sample.max >= 55 && sample.max <= 70, + (sample) => sample.max === darkCorner.max, ); assert( darkNormal.max >= 55 && darkNormal.max < center.max, diff --git a/scripts/refinement-checks.mjs b/scripts/refinement-checks.mjs index 8c3011a..cd75791 100644 --- a/scripts/refinement-checks.mjs +++ b/scripts/refinement-checks.mjs @@ -10,7 +10,7 @@ export async function refinementChecks(page, load) { await page .locator(":root") .evaluate((el) => getComputedStyle(el).getPropertyValue("--dot")), - /0?\.3\)/, + /0?\.42\)/, ); const project = page.locator('a.folder[href="/work/body-language"]'); await project.scrollIntoViewIfNeeded(); From a458d6e1d3ed2ae2689ffe7e93716e500d10bd0c Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Sat, 12 Sep 2026 22:51:58 +0530 Subject: [PATCH 03/22] fix(portfolio): refine dots and sync spring navigation with scroll --- cursor.js | 2 +- docs/design-system.md | 6 +++--- script.js | 32 ++++++++++++++++++-------------- scripts/dot-checks.mjs | 4 ++-- scripts/refinement-checks.mjs | 2 +- scripts/verify.mjs | 8 ++++++++ style.css | 4 ++-- 7 files changed, 35 insertions(+), 23 deletions(-) diff --git a/cursor.js b/cursor.js index 1c584cf..124e589 100644 --- a/cursor.js +++ b/cursor.js @@ -187,7 +187,7 @@ dots.forEach((points, index) => { if (!points.length) return; const proximity = index / 24; - const radius = 1.15 + proximity * (dark ? 1.45 : 2.15); + const radius = 1.15 + proximity * (dark ? 1.2 : 1.85); context.fillStyle = "#" + channels diff --git a/docs/design-system.md b/docs/design-system.md index 6945649..777e1b9 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -9,7 +9,7 @@ The portfolio uses a warm dotted canvas, expressive serif type, translucent past | Canvas | #f5f4f0 | | Ink | #1a1a1a | | Accent | #3b5bdb | -| Grid | 28px dots, rgb(80 80 75 / 0.42); dark rgb(200 200 210 / 0.27), masked beneath content | +| Grid | 28px dots, rgb(80 80 75 / 0.36); dark rgb(200 200 210 / 0.23), masked beneath content | | Display | Instrument Serif, regular and italic | | Body and controls | Instrument Sans, 400–700 | | Handwritten asides | Nanum Pen Script | @@ -33,7 +33,7 @@ Mobile uses top identity/menu controls, one-column collections, smaller type, tw ## Motion -Folder transforms and hover lifts use ease-out and spring-like curves. Pointer movement locally enlarges dots within a soft radius. Word-sized masks protect visible text with one pixel of padding; rounded masks protect painted UI surfaces. Transparent links and layout wrappers do not clear their surrounding area, and hovering controls keeps nearby dots reactive. Normal dots retain uniform opacity through all four edges and corners; only the pointer highlight blends back into the grid over a 225px radius. Pointer falloff blends dot size and opacity; dark mode uses a quieter base and highlight. Touch and reduced-motion devices retain only static dots; rendering stops when idle. Without JavaScript, the page uses the plain canvas color. Soft shadows replace decorative borders. Mouse focus avoids oversized rings while keyboard focus remains visible. The game starts only on input. Reduced-motion styles suppress decorative movement. +Folder transforms and hover lifts use ease-out and spring-like curves. Pointer movement locally enlarges dots within a soft radius. Word-sized masks protect visible text with one pixel of padding; rounded masks protect painted UI surfaces. Transparent links and layout wrappers do not clear their surrounding area, and hovering controls keeps nearby dots reactive. Normal dots retain uniform opacity through all four edges and corners; only the pointer highlight blends back into the grid over a 225px radius. Pointer falloff blends dot size and opacity; dark mode uses a quieter base and highlight. Hover dot radii peak at 3px in light mode and 2.35px in dark mode; highlight colors and opacity remain unchanged. Touch and reduced-motion devices retain only static dots; rendering stops when idle. Without JavaScript, the page uses the plain canvas color. Soft shadows replace decorative borders. Mouse focus avoids oversized rings while keyboard focus remains visible. The game starts only on input. Reduced-motion styles suppress decorative movement. The Interactions section of Play Lab contains six working demos: shape switching, selectable spring motion, a tilting/turning studio pass, click celebration, an adjustable spinning petal bloom and a tactile ripple pond with three palettes. A keyboard-accessible segmented control shows exactly one panel at a time, with a short entrance transition. The canvas camera pans independently of cards; zooming out exposes usable world space. Dragging and throwing cards preserves momentum, while mobile defaults to a readable list with an optional canvas view. The physics demo integrates a damped spring with drag velocity and three response presets. Celebration has a clear burst and halo. The water demo propagates a height field and refracts a textured bed, then settles; reduced motion produces a still wave pattern. Keyboard, touch and reduced-motion feedback remain available. @@ -58,6 +58,6 @@ The generated SVGs in assets/about and assets/placeholders are explicitly labele Capabilities open the first row when visible, fill a bright green progress ring over four seconds, then cycle forward. Each detail is one concise paragraph with no hover state. Selecting any row closes the previous one and restarts the timer. Offscreen and hidden tabs stop the cycle. Home’s personal cards share one integrated Quick Ask surface; mobile users can swipe through all five cards. The About photo fan lifts, tilts and pins individual photos with pointer, tap and keyboard controls. -Desktop navigation starts fully expanded and collapses after 32px of scrolling, while preserving focused navigation links. The menu button reopens it, and mobile keeps its compact initial menu. Navigation width changes use a short spring-shaped transition. Button presses, tabs, expandable content and cards have restrained motion. Reduced-motion preferences disable animated transitions and momentum without removing control feedback. The compact wallet shares a single white chat surface; messages load for at least two seconds, remain for ten seconds, and close on an outside click. +Desktop navigation is fully expanded at the top (within 1px), collapses as soon as scrolling moves downward, and reopens when returning to the top. A focused navigation link returns focus to the menu button on collapse. Width transitions use a restrained 4px opening overshoot and 3px closing undershoot, settling over 440ms and 360ms respectively. The menu button reopens it, and mobile keeps its compact initial menu. Navigation width changes use a short spring-shaped transition. Button presses, tabs, expandable content and cards have restrained motion. Reduced-motion preferences disable animated transitions and momentum without removing control feedback. The compact wallet shares a single white chat surface; messages load for at least two seconds, remain for ten seconds, and close on an outside click. Wallet cards are 175px tall, their decorative top starts at 0px, and the wallet margin is 44px auto -20px at every breakpoint. Mobile retains a horizontally scrollable fan. diff --git a/script.js b/script.js index 183443d..0a7e62a 100644 --- a/script.js +++ b/script.js @@ -12,14 +12,14 @@ function setMenu(open, restoreFocus = false) { return; } const changed = nav.dataset.open !== undefined; - navMotion?.cancel(); - shellMotion?.cancel(); - delete nav.dataset.closing; const shell = document.querySelector(".nav-shell"); const previousWidth = changed && !smallScreen.matches && !reducedMotion.matches ? shell.getBoundingClientRect().width : 0; + navMotion?.cancel(); + shellMotion?.cancel(); + delete nav.dataset.closing; nav.dataset.open = String(open); nav.inert = !open && smallScreen.matches; menu.setAttribute("aria-expanded", String(open)); @@ -47,9 +47,10 @@ function setMenu(open, restoreFocus = false) { ] : [ { opacity: 1, transform: "none" }, + { opacity: 1, transform: "translateY(1px) scale(1.004)", offset: 0.18 }, { opacity: 0, transform: "translateY(-5px) scale(.97)" }, ], - { duration: open ? 420 : 200, easing: "cubic-bezier(.22,1,.36,1)" }, + { duration: open ? 420 : 320, easing: "cubic-bezier(.22,1,.36,1)" }, ); navMotion.onfinish = () => { delete nav.dataset.closing; @@ -58,10 +59,10 @@ function setMenu(open, restoreFocus = false) { shellMotion = shell.animate( [ { width: previousWidth + "px" }, - { width: nextWidth + (open ? 5 : -3) + "px", offset: 0.76 }, + { width: nextWidth + (open ? 4 : -3) + "px", offset: 0.76 }, { width: nextWidth + "px" }, ], - { duration: open ? 460 : 240, easing: "cubic-bezier(.22,1,.36,1)" }, + { duration: open ? 440 : 360, easing: "cubic-bezier(.22,1,.36,1)" }, ); } } @@ -70,7 +71,9 @@ function setMenu(open, restoreFocus = false) { if (nav && menu) { document.documentElement.classList.add("nav-ready"); menu.hidden = false; - setMenu(!smallScreen.matches); + const atTop = () => window.scrollY <= 1; + const syncNavigation = () => setMenu(!smallScreen.matches && atTop()); + syncNavigation(); menu.addEventListener("click", () => setMenu(menu.getAttribute("aria-expanded") !== "true"), ); @@ -96,18 +99,19 @@ if (nav && menu) { } }), ); - smallScreen.addEventListener("change", () => setMenu(!smallScreen.matches)); + smallScreen.addEventListener("change", syncNavigation); + window.addEventListener("pageshow", syncNavigation); let previousScroll = window.scrollY; window.addEventListener( "scroll", () => { - if ( - !smallScreen.matches && - Math.abs(window.scrollY - previousScroll) > 32 - ) { - if (!nav.contains(document.activeElement)) setMenu(false); - previousScroll = window.scrollY; + const currentScroll = Math.max(0, window.scrollY); + if (!smallScreen.matches) { + if (atTop()) setMenu(true); + else if (currentScroll > previousScroll) + setMenu(false, nav.contains(document.activeElement)); } + previousScroll = currentScroll; }, { passive: true }, ); diff --git a/scripts/dot-checks.mjs b/scripts/dot-checks.mjs index 689c59f..ee29588 100644 --- a/scripts/dot-checks.mjs +++ b/scripts/dot-checks.mjs @@ -181,7 +181,7 @@ export async function dotChecks(page, load) { ]); const corner = await point(14, 14); assert( - center.max >= 90 && + center.max >= 80 && edges.every((edge) => edge.max === center.max), "Normal light dots stay visible at equal opacity through all four edges", ); @@ -223,7 +223,7 @@ export async function dotChecks(page, load) { (sample) => sample.max === darkCorner.max, ); assert( - darkNormal.max >= 55 && darkNormal.max < center.max, + darkNormal.max >= 50 && darkNormal.max < center.max, `Dark normal dots remain clearly visible: ${JSON.stringify({ center, darkNormal })}`, ); assert( diff --git a/scripts/refinement-checks.mjs b/scripts/refinement-checks.mjs index cd75791..c3c8231 100644 --- a/scripts/refinement-checks.mjs +++ b/scripts/refinement-checks.mjs @@ -10,7 +10,7 @@ export async function refinementChecks(page, load) { await page .locator(":root") .evaluate((el) => getComputedStyle(el).getPropertyValue("--dot")), - /0?\.42\)/, + /0?\.36\)/, ); const project = page.locator('a.folder[href="/work/body-language"]'); await project.scrollIntoViewIfNeeded(); diff --git a/scripts/verify.mjs b/scripts/verify.mjs index d0e4284..c576313 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -285,6 +285,14 @@ async function navigation(page) { await page.locator('#site-nav a[href="/play-lab"]').isVisible(), "Full desktop navigation links are visible on load", ); + await page.evaluate(() => scrollTo({ top: 2, behavior: "instant" })); + await page.waitForFunction( + () => document.querySelector(".menu-toggle").getAttribute("aria-expanded") === "false", + ); + await page.evaluate(() => scrollTo({ top: 0, behavior: "instant" })); + await page.waitForFunction( + () => document.querySelector(".menu-toggle").getAttribute("aria-expanded") === "true", + ); await page.evaluate(() => scrollTo({ top: 80, behavior: "instant" })); await page.waitForFunction( () => document.querySelector(".menu-toggle").getAttribute("aria-expanded") === "false", diff --git a/style.css b/style.css index 51d232f..5abf3cd 100644 --- a/style.css +++ b/style.css @@ -30,7 +30,7 @@ --line: rgb(0 0 0 / 0.075); --glass: #fbfaf7; --strong-glass: #fff; - --dot: rgb(80 80 75 / 0.42); + --dot: rgb(80 80 75 / 0.36); --out: cubic-bezier(0.23, 1, 0.32, 1); --spring: cubic-bezier(0.34, 1.4, 0.64, 1); color-scheme: light; @@ -43,7 +43,7 @@ --line: rgb(255 255 255 / 0.1); --glass: #292a31; --strong-glass: #34353d; - --dot: rgb(200 200 210 / 0.27); + --dot: rgb(200 200 210 / 0.23); color-scheme: dark; } * { From 4e0f0535925812d82c171b08179fcc2bc4a55393 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Sat, 12 Sep 2026 23:01:25 +0530 Subject: [PATCH 04/22] style(nav): add frosted glass and slower spring transitions --- docs/design-system.md | 4 ++-- script.js | 4 ++-- style.css | 42 ++++++++++++++++++++++++++++++++++++------ 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/docs/design-system.md b/docs/design-system.md index 777e1b9..e00e375 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -14,7 +14,7 @@ The portfolio uses a warm dotted canvas, expressive serif type, translucent past | Body and controls | Instrument Sans, 400–700 | | Handwritten asides | Nanum Pen Script | | Foreground surfaces | Opaque light/dark surfaces over the dotted canvas | -| Navigation blur | 22px | +| Navigation glass | 26px blur, 160% saturation, translucent theme tint, fine rim and inset highlight | | Contact canvas | #0a0a0a | The six folder tints are #b8f0d8, #d4c9f5, #b8cef5, #f5d4b8, #f2a65a and #c8e6c0. Folder labels use dark ink for readable contrast. Each folder has three fanned sheets, a frosted front starting at 32% height, and a 400:340 proportion. @@ -58,6 +58,6 @@ The generated SVGs in assets/about and assets/placeholders are explicitly labele Capabilities open the first row when visible, fill a bright green progress ring over four seconds, then cycle forward. Each detail is one concise paragraph with no hover state. Selecting any row closes the previous one and restarts the timer. Offscreen and hidden tabs stop the cycle. Home’s personal cards share one integrated Quick Ask surface; mobile users can swipe through all five cards. The About photo fan lifts, tilts and pins individual photos with pointer, tap and keyboard controls. -Desktop navigation is fully expanded at the top (within 1px), collapses as soon as scrolling moves downward, and reopens when returning to the top. A focused navigation link returns focus to the menu button on collapse. Width transitions use a restrained 4px opening overshoot and 3px closing undershoot, settling over 440ms and 360ms respectively. The menu button reopens it, and mobile keeps its compact initial menu. Navigation width changes use a short spring-shaped transition. Button presses, tabs, expandable content and cards have restrained motion. Reduced-motion preferences disable animated transitions and momentum without removing control feedback. The compact wallet shares a single white chat surface; messages load for at least two seconds, remain for ten seconds, and close on an outside click. +Desktop navigation is fully expanded at the top (within 1px), collapses as soon as scrolling moves downward, and reopens when returning to the top. A focused navigation link returns focus to the menu button on collapse. Width transitions use a restrained 4px opening overshoot and 3px closing undershoot, settling over 600ms and 500ms respectively. The menu button reopens it, and mobile keeps its compact initial menu. Navigation width changes use a short spring-shaped transition. Button presses, tabs, expandable content and cards have restrained motion. Reduced-motion preferences disable animated transitions and momentum without removing control feedback. The compact wallet shares a single white chat surface; messages load for at least two seconds, remain for ten seconds, and close on an outside click. Wallet cards are 175px tall, their decorative top starts at 0px, and the wallet margin is 44px auto -20px at every breakpoint. Mobile retains a horizontally scrollable fan. diff --git a/script.js b/script.js index 0a7e62a..4f29b07 100644 --- a/script.js +++ b/script.js @@ -50,7 +50,7 @@ function setMenu(open, restoreFocus = false) { { opacity: 1, transform: "translateY(1px) scale(1.004)", offset: 0.18 }, { opacity: 0, transform: "translateY(-5px) scale(.97)" }, ], - { duration: open ? 420 : 320, easing: "cubic-bezier(.22,1,.36,1)" }, + { duration: open ? 580 : 460, easing: "cubic-bezier(.22,1,.36,1)" }, ); navMotion.onfinish = () => { delete nav.dataset.closing; @@ -62,7 +62,7 @@ function setMenu(open, restoreFocus = false) { { width: nextWidth + (open ? 4 : -3) + "px", offset: 0.76 }, { width: nextWidth + "px" }, ], - { duration: open ? 440 : 360, easing: "cubic-bezier(.22,1,.36,1)" }, + { duration: open ? 600 : 500, easing: "cubic-bezier(.22,1,.36,1)" }, ); } } diff --git a/style.css b/style.css index 5abf3cd..7730c09 100644 --- a/style.css +++ b/style.css @@ -30,6 +30,9 @@ --line: rgb(0 0 0 / 0.075); --glass: #fbfaf7; --strong-glass: #fff; + --nav-glass: rgb(251 250 247 / 0.68); + --nav-sheen: rgb(255 255 255 / 0.38); + --nav-edge: rgb(255 255 255 / 0.65); --dot: rgb(80 80 75 / 0.36); --out: cubic-bezier(0.23, 1, 0.32, 1); --spring: cubic-bezier(0.34, 1.4, 0.64, 1); @@ -43,6 +46,9 @@ --line: rgb(255 255 255 / 0.1); --glass: #292a31; --strong-glass: #34353d; + --nav-glass: rgb(35 36 43 / 0.72); + --nav-sheen: rgb(255 255 255 / 0.07); + --nav-edge: rgb(255 255 255 / 0.14); --dot: rgb(200 200 210 / 0.23); color-scheme: dark; } @@ -257,13 +263,28 @@ input { align-items: center; gap: 10px; padding: 6px 8px; - border: 0; + border: 1px solid var(--nav-edge); border-radius: 50px; - background: var(--glass); - backdrop-filter: blur(22px) saturate(180%); + background: linear-gradient(145deg, var(--nav-sheen), transparent 65%) var(--nav-glass); + -webkit-backdrop-filter: blur(26px) saturate(160%); + backdrop-filter: blur(26px) saturate(160%); box-shadow: - 0 8px 30px #00000012, - 0 1px 4px #0000000a; + inset 0 1px 0 var(--nav-sheen), + inset 0 -1px 0 rgb(0 0 0 / 0.04), + 0 12px 36px rgb(0 0 0 / 0.12), + 0 2px 8px rgb(0 0 0 / 0.04); +} +@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { + @media (min-width: 768px) { + .site-header .nav-shell { + background: var(--glass); + } + } + @media (max-width: 767px) { + .site-header .nav-shell :is(.wordmark, .menu-toggle, .theme-toggle, #site-nav) { + background: var(--glass); + } + } } .wordmark { font: @@ -314,7 +335,7 @@ input { width: 15px; height: 1px; background: currentColor; - transition: transform 0.25s; + transition: transform 0.4s var(--out); } .menu-toggle[aria-expanded="true"] .menu-glyph i:first-child { transform: translateY(3px) rotate(45deg); @@ -1998,6 +2019,7 @@ input { border-radius: 0; padding: 0; background: transparent; + -webkit-backdrop-filter: none; backdrop-filter: none; box-shadow: none; justify-content: center; @@ -2077,6 +2099,14 @@ input { .nav-ready #site-nav[data-open="false"] { display: none; } + .nav-shell :is(.wordmark, .menu-toggle, .theme-toggle), + .nav-shell #site-nav { + border: 1px solid var(--nav-edge); + background: linear-gradient(145deg, var(--nav-sheen), transparent 65%) var(--nav-glass); + -webkit-backdrop-filter: blur(26px) saturate(160%); + backdrop-filter: blur(26px) saturate(160%); + box-shadow: inset 0 1px 0 var(--nav-sheen), 0 8px 28px rgb(0 0 0 / 0.1); + } html:not(.nav-ready) .site-header { position: fixed; top: 14px; From 7130a9b80b2927ba47cf1539bb4b4ffcc5f4a11f Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Sat, 12 Sep 2026 23:06:29 +0530 Subject: [PATCH 05/22] style(nav): show theme button circle only on hover --- style.css | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/style.css b/style.css index 7730c09..97eaabc 100644 --- a/style.css +++ b/style.css @@ -2324,6 +2324,13 @@ input { } .nav-shell > .theme-toggle { order: 8; + background: transparent; + transition: background-color 0.2s ease; + } + @media (hover: hover) { + .nav-shell > .theme-toggle:hover { + background: var(--glass); + } } .nav-ready #site-nav[data-open="false"] From 7f4ce12dbb6f3c1046126902b974c0d1b508cab5 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Sat, 12 Sep 2026 23:19:00 +0530 Subject: [PATCH 06/22] feat(play-lab): add wheel zoom and soften interaction effects --- docs/design-system.md | 2 +- play-lab.html | 4 +- playground.css | 24 +------ playground.js | 132 +++++++++++++------------------------- scripts/motion-checks.mjs | 16 +++++ scripts/playground.mjs | 4 +- 6 files changed, 67 insertions(+), 115 deletions(-) diff --git a/docs/design-system.md b/docs/design-system.md index e00e375..9ef3355 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -35,7 +35,7 @@ Mobile uses top identity/menu controls, one-column collections, smaller type, tw Folder transforms and hover lifts use ease-out and spring-like curves. Pointer movement locally enlarges dots within a soft radius. Word-sized masks protect visible text with one pixel of padding; rounded masks protect painted UI surfaces. Transparent links and layout wrappers do not clear their surrounding area, and hovering controls keeps nearby dots reactive. Normal dots retain uniform opacity through all four edges and corners; only the pointer highlight blends back into the grid over a 225px radius. Pointer falloff blends dot size and opacity; dark mode uses a quieter base and highlight. Hover dot radii peak at 3px in light mode and 2.35px in dark mode; highlight colors and opacity remain unchanged. Touch and reduced-motion devices retain only static dots; rendering stops when idle. Without JavaScript, the page uses the plain canvas color. Soft shadows replace decorative borders. Mouse focus avoids oversized rings while keyboard focus remains visible. The game starts only on input. Reduced-motion styles suppress decorative movement. -The Interactions section of Play Lab contains six working demos: shape switching, selectable spring motion, a tilting/turning studio pass, click celebration, an adjustable spinning petal bloom and a tactile ripple pond with three palettes. A keyboard-accessible segmented control shows exactly one panel at a time, with a short entrance transition. The canvas camera pans independently of cards; zooming out exposes usable world space. Dragging and throwing cards preserves momentum, while mobile defaults to a readable list with an optional canvas view. The physics demo integrates a damped spring with drag velocity and three response presets. Celebration has a clear burst and halo. The water demo propagates a height field and refracts a textured bed, then settles; reduced motion produces a still wave pattern. Keyboard, touch and reduced-motion feedback remain available. +The Interactions section of Play Lab contains six working demos: shape switching, selectable spring motion, a tilting/turning studio pass, click celebration, an adjustable spinning petal bloom and a tactile ripple pond with three palettes. A keyboard-accessible segmented control shows exactly one panel at a time, with a short entrance transition. The canvas camera pans independently of cards; wheel scrolling zooms around the pointer from 65% to 165%, while scrolling outside the canvas or in List view scrolls the page. Zooming out exposes usable world space. Dragging and throwing cards preserves momentum, while mobile defaults to a readable list with an optional canvas view. The physics demo integrates a damped spring with drag velocity and three response presets. Celebration has a clear burst and halo. The water demo draws soft, low-opacity pastel wave bands without refraction, caustics or specular reflections, then settles; reduced motion produces a still wave pattern. The Depth card tilts over 600ms and flips over 900ms. Physics keeps its draggable spring motion without a visible coil or anchor ring. Keyboard, touch and reduced-motion feedback remain available. ## Self-hosted font assets and licenses diff --git a/play-lab.html b/play-lab.html index 67885b5..bccf530 100644 --- a/play-lab.html +++ b/play-lab.html @@ -51,10 +51,10 @@ -

Grab a card and give it a little throw. Drag the empty canvas to explore; zoom out for more room. Arrow keys move a focused card or pan the canvas. Buttons and inputs work as usual.

+

Grab a card and give it a little throw. Drag the empty canvas to explore. Scroll over it to zoom at your pointer; zoom out for more room. Arrow keys move a focused card or pan the canvas. Buttons and inputs work as usual.

Interaction experiments

01 / FORM

A change of shape

One form, four personalities. Pick your favourite.

-

02 / PHYSICS

Just the right bounce

Pull, let go, and find the spring that feels right.

+

02 / PHYSICS

Just the right bounce

Pull, let go, and find the spring that feels right.

Move around it. Tap to turn it over.

03 / DEPTH

A different perspective

A little depth on hover, a new thought on the other side.

Go on. Make someone's day. Yours counts.

04 / FEEDBACK

A good click goes a long way

A full burst of colour, because small things count.

05 / PLAYFUL GEOMETRY

Make room for a little wonder

Open the petals. Follow your pointer. Find your own rhythm.

diff --git a/playground.css b/playground.css index 0b39098..a2c48c2 100644 --- a/playground.css +++ b/playground.css @@ -851,28 +851,6 @@ outline: 2px solid #395732; outline-offset: 5px; } -.spring-tether { - position: absolute; - left: var(--spring-left, 0px); - width: var(--spring-length, 0px); - height: 12px; - background: repeating-linear-gradient( - 70deg, - transparent 0 5px, - #759268 5px 7px, - transparent 7px 12px - ); - opacity: 0.75; -} -.interaction-spring-track::after { - content: ""; - position: absolute; - left: calc(var(--spring-anchor, 0px) - 9px); - width: 18px; - height: 18px; - border: 2px solid #759268; - border-radius: 50%; -} .interaction-depth-stage { background: #dde7f1; gap: 23px; @@ -894,7 +872,7 @@ inset 0 1px 2px #ffffff62; transform: rotateX(var(--tilt-x)) rotateY(var(--tilt-y)) rotate(-7deg); transition: - transform 0.2s, + transform 0.6s var(--out), background 0.3s; } .interaction-depth-card[aria-pressed="true"] { diff --git a/playground.js b/playground.js index c1d3aa0..f049301 100644 --- a/playground.js +++ b/playground.js @@ -438,28 +438,35 @@ save(); }), ); + function zoomAt(next, centerX, centerY) { + stopMovement(); + const oldScale = fit * zoom; + const worldX = (centerX - camera.x) / oldScale; + const worldY = (centerY - camera.y) / oldScale; + zoom = Math.min(1.65, Math.max(0.65, next)); + arrange(); + camera.x = centerX - worldX * fit * zoom; + camera.y = centerY - worldY * fit * zoom; + paintCamera(); + } + board.addEventListener("wheel", (event) => { + if (list || board.closest("[hidden]") || drag || panDrag || + event.target.closest("input, textarea, select")) return; + if (!event.deltaY) return; + event.preventDefault(); + const rect = board.getBoundingClientRect(); + const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? board.clientHeight : 1; + const delta = Math.max(-160, Math.min(160, event.deltaY * unit)); + zoomAt(zoom * Math.exp(-delta * 0.0015), + event.clientX - rect.left - board.clientLeft, + event.clientY - rect.top - board.clientTop); + }, { passive: false }); board.querySelectorAll("[data-playground-zoom]").forEach((button) => button.addEventListener("click", () => { - stopMovement(); - const oldScale = fit * zoom; const centerX = board.clientWidth / 2, centerY = (board.clientHeight - 88) / 2; - const worldX = (centerX - camera.x) / oldScale, - worldY = (centerY - camera.y) / oldScale; - zoom = Math.min( - 1.65, - Math.max( - 0.65, - Math.round( - (zoom + (button.dataset.playgroundZoom === "in" ? 0.15 : -0.15)) * - 100, - ) / 100, - ), - ); - arrange(); - camera.x = centerX - worldX * fit * zoom; - camera.y = centerY - worldY * fit * zoom; - paintCamera(); + zoomAt(Math.round((zoom + (button.dataset.playgroundZoom === "in" ? 0.15 : -0.15)) * 100) / 100, + centerX, centerY); announce(`Canvas zoom ${Math.round(zoom * 100)} percent.`); }), ); @@ -826,15 +833,6 @@ ? 0 : Math.min(0.17, Math.abs(springState.velocity) / 5000); ball.style.transform = `translateX(${springState.x - 38.5}px) scale(${1 + speed}, ${1 - speed * 0.7})`; - springTrack.style.setProperty("--spring-anchor", `${springState.target}px`); - springTrack.style.setProperty( - "--spring-length", - `${Math.abs(springState.x - springState.target)}px`, - ); - springTrack.style.setProperty( - "--spring-left", - `${Math.min(springState.x, springState.target)}px`, - ); ball.dataset.springState = springFrame || springDrag ? "moving" : "settled"; } function stopSpring() { @@ -1022,7 +1020,7 @@ }, { transform: "rotateY(0deg) rotate(-7deg) scale(1)" }, ], - { duration: 560, easing: "cubic-bezier(.18,.7,.2,1)" }, + { duration: 900, easing: "cubic-bezier(.18,.7,.2,1)" }, ); }); const resetTilt = () => { @@ -1164,65 +1162,25 @@ heights, previousHeights, waterPixels, - bedPixels; - function waterBed() { - if (!waterWidth) return; + waterTint; + function setWaterPalette() { const theme = pond.closest(".interaction-ripple-stage").dataset.rippleTheme; - const palette = { - sky: [92, 163, 181], - mint: [116, 162, 131], - rose: [183, 129, 151], + waterTint = { + sky: [110, 165, 185], + mint: [123, 161, 134], + rose: [185, 139, 161], }[theme]; - bedPixels = new Uint8ClampedArray(waterWidth * waterHeight * 3); - for (let y = 0; y < waterHeight; y++) - for (let x = 0; x < waterWidth; x++) { - // A shallow textured bed gives moving surface normals something to refract. - const depth = Math.hypot( - (x / waterWidth - 0.33) * 0.9, - (y / waterHeight - 0.23) * 0.75, - ); - const caustic = - Math.sin(x * 0.105 + Math.sin(y * 0.074) * 2.2) * - Math.sin(y * 0.091 + Math.cos(x * 0.07) * 2.4); - const light = 37 - depth * 46 + Math.pow(Math.max(0, caustic), 5) * 48; - const grain = Math.sin(x * 23.7 + y * 11.3) * 1.8; - const index = (y * waterWidth + x) * 3; - for (let c = 0; c < 3; c++) - bedPixels[index + c] = palette[c] + light + grain; - } } function paintWater() { if (!waterContext || !waterWidth) return; const pixels = waterPixels.data; - for (let y = 0; y < waterHeight; y++) - for (let x = 0; x < waterWidth; x++) { - const i = y * waterWidth + x; - const dx = (heights[i - 1] || 0) - (heights[i + 1] || 0); - const dy = - (heights[i - waterWidth] || 0) - (heights[i + waterWidth] || 0); - const rx = Math.max( - 0, - Math.min(waterWidth - 1, Math.round(x + dx * 1.7)), - ); - const ry = Math.max( - 0, - Math.min(waterHeight - 1, Math.round(y + dy * 1.7)), - ); - const sample = (ry * waterWidth + rx) * 3; - const slope = Math.max(-30, Math.min(40, dx * -4 + dy * -6)); - const reflection = Math.min( - 100, - Math.max(0, dx * -0.5 + dy * -0.8) ** 2 * 9, - ); - // Keep the pastel CSS pond visible at rest. The same refracted bed and - // surface normals supply only the moving light and shade above it. - let light = slope + reflection; - for (let c = 0; c < 3; c++) - light += (bedPixels[sample + c] - bedPixels[i * 3 + c]) / 3; - for (let c = 0; c < 3; c++) - pixels[i * 4 + c] = light > 0 ? 255 : bedPixels[i * 3 + c] * 0.42; - pixels[i * 4 + 3] = Math.min(170, Math.abs(light) * 3); - } + // Flat pastel wave bands, without refraction, caustics or specular glare. + for (let i = 0; i < heights.length; i++) { + const crest = heights[i]; + for (let c = 0; c < 3; c++) + pixels[i * 4 + c] = crest >= 0 ? 255 : waterTint[c]; + pixels[i * 4 + 3] = Math.min(72, Math.abs(crest) * 8); + } waterContext.putImageData(waterPixels, 0, 0); } function sizeWater() { @@ -1235,7 +1193,7 @@ heights = new Float32Array(width * height); previousHeights = new Float32Array(width * height); waterPixels = waterContext.createImageData(width, height); - waterBed(); + setWaterPalette(); paintWater(); pond.dataset.waterState = "resting"; } @@ -1253,7 +1211,7 @@ heights[i + waterWidth]) * 0.5 - previousHeights[i]) * - 0.982; + 0.97; previousHeights[i] = next; energy = Math.max(energy, Math.abs(next)); } @@ -1315,20 +1273,20 @@ const distance = Math.hypot(x - cx, y - cy) / radius; if (distance < 1) heights[y * waterWidth + x] += - Math.cos((distance * Math.PI) / 2) * (announce ? 24 : 10); + Math.cos((distance * Math.PI) / 2) * (announce ? 12 : 5); } water.dataset.rippleOrigin = `${Math.round(cx)},${Math.round(cy)}`; if (announce) { ripples++; pond.dataset.rippleCount = String(ripples); - rippleStatus.textContent = `${ripples} ${ripples === 1 ? "ripple" : "ripples"} made. Watch the light follow the water.`; + rippleStatus.textContent = `${ripples} ${ripples === 1 ? "ripple" : "ripples"} made. Watch the soft rings spread.`; } if (motion.matches) { for (let i = 0; i < 22; i++) stepWater(); paintWater(); pond.dataset.waterState = "still"; } else { - waterUntil = performance.now() + 7000; + waterUntil = performance.now() + 4500; pond.dataset.waterState = "moving"; if (!waterFrame) { waterTime = performance.now(); @@ -1361,7 +1319,7 @@ .querySelectorAll("[data-ripple-colour]") .forEach((b) => b.setAttribute("aria-pressed", String(b === button))); rippleStatus.textContent = `${button.textContent} water selected. Make a ripple.`; - waterBed(); + setWaterPalette(); paintWater(); }); }); diff --git a/scripts/motion-checks.mjs b/scripts/motion-checks.mjs index db83240..767f122 100644 --- a/scripts/motion-checks.mjs +++ b/scripts/motion-checks.mjs @@ -61,6 +61,20 @@ export async function canvasMotion(page, load) { const board = page.locator("[data-playground-board]"); const world = page.locator(".playground-world"); const card = page.locator(".playground-clock"); + await board.scrollIntoViewIfNeeded(); + const zoomLabel = page.locator("[data-playground-zoom-label]"); + const wheelBox = await board.boundingBox(); + await page.mouse.move(wheelBox.x + 12, wheelBox.y + 100); + const scrollBeforeWheel = await page.evaluate(() => scrollY); + await page.mouse.wheel(0, -120); + await page.waitForFunction(() => + parseInt(document.querySelector("[data-playground-zoom-label]").value) > 100); + assert.equal(await page.evaluate(() => scrollY), scrollBeforeWheel, + "Scrolling over canvas zooms without scrolling the page"); + await page.mouse.wheel(0, 120); + await page.waitForFunction(() => + document.querySelector("[data-playground-zoom-label]").value === "100%"); + assert.equal(await zoomLabel.innerText(), "100%", "Wheel zoom works in both directions"); for (let i = 0; i < 3; i++) await page.locator('[data-playground-zoom="out"]').click(); assert.equal( @@ -105,6 +119,8 @@ export async function canvasMotion(page, load) { assert(await board.isVisible()); await page.locator("#canvas-tab").press("ArrowRight"); const ball = page.locator(".interaction-spring-ball"); + assert.equal(await page.locator(".spring-tether").count(), 0, + "Physics ball has no decorative spring wire"); await page.locator('[data-spring-character="bouncy"]').click(); const initialBall = await ball.boundingBox(); await page.locator("[data-spring-launch]").click(); diff --git a/scripts/playground.mjs b/scripts/playground.mjs index 1127d15..f524ded 100644 --- a/scripts/playground.mjs +++ b/scripts/playground.mjs @@ -28,14 +28,14 @@ export function renderPlayground({ profile = {} } = {}) { -

Grab a card and give it a little throw. Drag the empty canvas to explore; zoom out for more room. Arrow keys move a focused card or pan the canvas. Buttons and inputs work as usual.

+

Grab a card and give it a little throw. Drag the empty canvas to explore. Scroll over it to zoom at your pointer; zoom out for more room. Arrow keys move a focused card or pan the canvas. Buttons and inputs work as usual.

${renderInteractions()}`; } export function renderInteractions() { return `

Interaction experiments

${["flower", "circle", "square", "star"].map((shape) => ``).join("")}

01 / FORM

A change of shape

One form, four personalities. Pick your favourite.

-

02 / PHYSICS

Just the right bounce

Pull, let go, and find the spring that feels right.

+

02 / PHYSICS

Just the right bounce

Pull, let go, and find the spring that feels right.

Move around it. Tap to turn it over.

03 / DEPTH

A different perspective

A little depth on hover, a new thought on the other side.

Go on. Make someone's day. Yours counts.

04 / FEEDBACK

A good click goes a long way

A full burst of colour, because small things count.

05 / PLAYFUL GEOMETRY

Make room for a little wonder

Open the petals. Follow your pointer. Find your own rhythm.

From 0e2049cd715f6918ce01e49873dd34be40110af8 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Sat, 12 Sep 2026 23:42:33 +0530 Subject: [PATCH 07/22] feat(play-lab): recreate reference canvas around existing widgets --- docs/design-system.md | 4 +- play-lab.html | 12 ++-- playground.css | 76 ++++++++++++++++++++++ playground.js | 129 ++++++++++++++++++++++++++++++++++---- scripts/motion-checks.mjs | 12 ++++ scripts/playground.mjs | 12 ++-- 6 files changed, 221 insertions(+), 24 deletions(-) diff --git a/docs/design-system.md b/docs/design-system.md index 9ef3355..80c5668 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -26,7 +26,7 @@ The six folder tints are #b8f0d8, #d4c9f5, #b8cef5, #f5d4b8, #f2a65a and #c8e6c0 - Work: a centered heading, shared category/search controls, six featured folders and six archive folders. Curated order is preserved until an explicit date sort. Four private-work cards use original illustrations, sans-serif headlines and the article index’s clean art-and-copy layout. - Blog: a left-aligned engineering publication header, featured article, search/filter/sort controls and a card grid with original vector artwork. Local readers use clear sans-serif titles, an author byline, a sidebar section index, a generous text column and code panels. - Project details: large left-aligned title, rounded metadata tray, genuine project imagery or a labeled placeholder, sticky section navigation and next-project link. -- Play Lab: a rounded canvas with an editable sticky note, focus timer, checklist, personal vote, colour mixer, clock and project widget. Drag any non-control surface or use arrow keys; releases carry momentum and bounce softly at the canvas edges. Mobile starts in List view for readable controls. +- Play Lab: a rounded canvas with an editable sticky note, focus timer, checklist, personal vote, colour mixer, clock and project widget. Drag any non-control surface or use arrow keys; releases carry momentum and bounce softly at the canvas edges. The canvas uses a near-viewport rounded blue frame, six pastel swatches with a separate background reset, a floating pan/zoom hint, centered zoom/reset controls, and three draggable pins. The seven existing widget cards retain their content and visual design. Desktop and mobile start in Canvas view with all cards visible; List view is available for reading. - 404: a centered message, playable Bug Run panel and return-home link. Mobile uses top identity/menu controls, one-column collections, smaller type, two-column metadata with the third item spanning both, and a compact bottom reading index. Native links and article content remain usable without JavaScript. @@ -35,7 +35,7 @@ Mobile uses top identity/menu controls, one-column collections, smaller type, tw Folder transforms and hover lifts use ease-out and spring-like curves. Pointer movement locally enlarges dots within a soft radius. Word-sized masks protect visible text with one pixel of padding; rounded masks protect painted UI surfaces. Transparent links and layout wrappers do not clear their surrounding area, and hovering controls keeps nearby dots reactive. Normal dots retain uniform opacity through all four edges and corners; only the pointer highlight blends back into the grid over a 225px radius. Pointer falloff blends dot size and opacity; dark mode uses a quieter base and highlight. Hover dot radii peak at 3px in light mode and 2.35px in dark mode; highlight colors and opacity remain unchanged. Touch and reduced-motion devices retain only static dots; rendering stops when idle. Without JavaScript, the page uses the plain canvas color. Soft shadows replace decorative borders. Mouse focus avoids oversized rings while keyboard focus remains visible. The game starts only on input. Reduced-motion styles suppress decorative movement. -The Interactions section of Play Lab contains six working demos: shape switching, selectable spring motion, a tilting/turning studio pass, click celebration, an adjustable spinning petal bloom and a tactile ripple pond with three palettes. A keyboard-accessible segmented control shows exactly one panel at a time, with a short entrance transition. The canvas camera pans independently of cards; wheel scrolling zooms around the pointer from 65% to 165%, while scrolling outside the canvas or in List view scrolls the page. Zooming out exposes usable world space. Dragging and throwing cards preserves momentum, while mobile defaults to a readable list with an optional canvas view. The physics demo integrates a damped spring with drag velocity and three response presets. Celebration has a clear burst and halo. The water demo draws soft, low-opacity pastel wave bands without refraction, caustics or specular reflections, then settles; reduced motion produces a still wave pattern. The Depth card tilts over 600ms and flips over 900ms. Physics keeps its draggable spring motion without a visible coil or anchor ring. Keyboard, touch and reduced-motion feedback remain available. +The Interactions section of Play Lab contains six working demos: shape switching, selectable spring motion, a tilting/turning studio pass, click celebration, an adjustable spinning petal bloom and a tactile ripple pond with three palettes. A keyboard-accessible segmented control shows exactly one panel at a time, with a short entrance transition. The canvas camera pans independently of cards; wheel scrolling zooms around the pointer from 65% to 165%, while scrolling outside the canvas or in List view scrolls the page. Zooming out exposes usable world space. Dragging and throwing cards preserves momentum, while mobile offers a readable List view alongside its initial Canvas view. Pins can be dragged onto a card or selected and placed with the keyboard; selecting the card pin unpins it. View reset restores positions, zoom, and pins, while background reset only resets the canvas colour. The physics demo integrates a damped spring with drag velocity and three response presets. Celebration has a clear burst and halo. The water demo draws soft, low-opacity pastel wave bands without refraction, caustics or specular reflections, then settles; reduced motion produces a still wave pattern. The Depth card tilts over 600ms and flips over 900ms. Physics keeps its draggable spring motion without a visible coil or anchor ring. Keyboard, touch and reduced-motion feedback remain available. ## Self-hosted font assets and licenses diff --git a/play-lab.html b/play-lab.html index bccf530..cf14969 100644 --- a/play-lab.html +++ b/play-lab.html @@ -37,8 +37,9 @@

Play Lab

-
The little idea board

Move things around. Leave a little thought.

-
+
+ +

A little thought

Yours to edit.0 / 240
— Baivab

A moment of focus

05:00one thing at a time

@@ -48,10 +49,11 @@

Make a little colour

Good things
come in colour.
#D1C2F0

- - + +
Drag to pan Scroll to zoom
+
-

Grab a card and give it a little throw. Drag the empty canvas to explore. Scroll over it to zoom at your pointer; zoom out for more room. Arrow keys move a focused card or pan the canvas. Buttons and inputs work as usual.

+

Grab a card and give it a little throw. Drag the empty canvas to explore. Scroll over it to zoom at your pointer; zoom out for more room. Arrow keys move a focused card or pan the canvas. Buttons and inputs work as usual.

Interaction experiments

01 / FORM

A change of shape

One form, four personalities. Pick your favourite.

02 / PHYSICS

Just the right bounce

Pull, let go, and find the spring that feels right.

diff --git a/playground.css b/playground.css index a2c48c2..25c1608 100644 --- a/playground.css +++ b/playground.css @@ -1413,3 +1413,79 @@ .interaction-bloom .bloom-petal-7 { --petal: 7; } + +/* Reference-style canvas frame and floating tools; widget cards retain their design. */ +.page-surface:has(#canvas:not([hidden])) > .cursor-dot-layer { visibility: hidden; } +@media screen { + .play-lab-switch { margin: 14px auto 12px; padding: 4px; border-radius: 30px; } + .play-lab-switch a { min-height: 34px; padding: 8px 18px; border-radius: 24px; font-size: 12px; } + .playground-section { max-width: none; padding: 0 3.3vw 72px; } + .playground-board { --canvas-color: #c8e6f7; height: calc(100svh - 148px); min-height: 560px; border-radius: 32px; border: 1px solid rgb(255 255 255 / .6); background-image: radial-gradient(rgb(82 120 142 / .27) .9px, transparent 1.3px); } + .playground-board[data-canvas-color="pink"] { --canvas-color: #f9d6e4; } + .playground-board[data-canvas-color="peach"] { --canvas-color: #fbdbb8; } + .playground-board[data-canvas-color="cream"] { --canvas-color: #fbf0ae; } + .playground-board[data-canvas-color="green"] { --canvas-color: #d3f2c7; } + .playground-board[data-canvas-color="lilac"] { --canvas-color: #dcd0f5; } + .playground-swatch-pink { background: #f9d6e4; } + .playground-swatch-peach { background: #fbdbb8; } + .playground-swatch-cream { background: #fbf0ae; } + .playground-swatch-green { background: #d3f2c7; } + .playground-swatch-blue { background: #c8e6f7; } + .playground-swatch-lilac { background: #dcd0f5; } + .playground-board .playground-view, + .playground-pin-tray, + .playground-canvas-hint, + .playground-palette, + .playground-controls { position: absolute; z-index: 50; background: rgb(250 249 246 / .93); color: #4f5357; border: 1px solid rgb(255 255 255 / .7); box-shadow: 0 3px 12px rgb(44 65 76 / .05); -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px); } + .playground-board .playground-view { top: 16px; right: 16px; padding: 11px 16px; font-size: 12px; } + .playground-pin-tray { top: 16px; left: 16px; display: flex; gap: 6px; padding: 10px 12px; border-radius: 18px; } + .playground-pin-tool { width: 32px; height: 38px; border: 0; background: transparent; padding: 4px; cursor: grab; touch-action: none; border-radius: 8px; } + .playground-pin-tool[aria-pressed="true"] { outline: 2px solid #526575; outline-offset: 2px; } + [data-pin-colour="gold"], [data-pinned="gold"] { --pin: #a7791e; --pin-light: #d6b15d; } + [data-pin-colour="teal"], [data-pinned="teal"] { --pin: #24768d; --pin-light: #63b0c1; } + [data-pin-colour="violet"], [data-pinned="violet"] { --pin: #7951ac; --pin-light: #b591d8; } + .playground-pin-tool span, .playground-card-pin, .playground-pin-ghost { display: block; width: 24px; height: 24px; border: 0; border-radius: 50%; background: radial-gradient(circle at 35% 26%, var(--pin-light), var(--pin) 70%); box-shadow: inset 0 2px 2px #ffffff50, inset 0 -3px 2px #00000026, 0 7px 0 -1px var(--pin), 0 10px 5px #00000020; } + .playground-card-pin { position: absolute; z-index: 5; top: -10px; left: calc(50% - 12px); cursor: pointer; } + .playground-pin-ghost { position: fixed; z-index: 1000; transform: translate(-50%, -50%); pointer-events: none; } + .playground-ready .playground-card[data-pinned] { cursor: default; } + .playground-board.is-pinning .playground-card { cursor: crosshair; } + .playground-palette { left: 16px; bottom: 16px; gap: 7px; padding: 8px; border-radius: 24px; } + .playground-swatch { width: 23px; height: 23px; } + .playground-swatch[aria-pressed="true"] { box-shadow: 0 0 0 2px #fff, 0 0 0 3px #70818a; } + .playground-background-reset { border: 0; background: transparent; color: #737c82; font-size: 19px; width: 24px; height: 24px; border-radius: 50%; } + .playground-controls { left: 50%; right: auto; transform: translateX(-50%); bottom: 16px; padding: 0; border-radius: 14px; overflow: hidden; gap: 0; } + .playground-controls button { border-radius: 0; width: 38px; height: 38px; font-size: 22px; } + .playground-controls button + button { border-left: 1px solid rgb(80 90 100 / .1); } + .playground-canvas-hint { bottom: 78px; left: 50%; transform: translateX(-50%); border-radius: 24px; padding: 11px 16px; display: flex; align-items: center; gap: 8px; white-space: nowrap; font-size: 12px; pointer-events: none; } + .playground-board.is-list { min-height: 0; padding-top: 78px; } + .playground-board.is-list :is(.playground-canvas-hint, .playground-pin-tray, .playground-palette, .playground-controls), + .playground-board:not(.playground-ready) :is(.playground-canvas-hint, .playground-pin-tray) { display: none; } +} +@media screen and (max-width: 767px) { + .play-lab-switch { margin-top: 80px; } + .playground-section { padding: 0 12px 28px; } + .playground-board { border-radius: 24px; height: calc(100svh - 154px); min-height: 610px; } + .playground-world { width: 660px; height: 1440px; } + .playground-ready:not(.is-list) .playground-note { left: 30px; top: 30px; } + .playground-ready:not(.is-list) .playground-focus { left: 350px; top: 50px; } + .playground-ready:not(.is-list) .playground-todo { left: 35px; top: 390px; } + .playground-ready:not(.is-list) .playground-clock { left: 370px; top: 450px; } + .playground-ready:not(.is-list) .playground-vote { left: 30px; top: 770px; } + .playground-ready:not(.is-list) .playground-mixer { left: 350px; top: 800px; } + .playground-ready:not(.is-list) .playground-work { left: 190px; top: 1120px; } + .playground-pin-tray { top: 12px; left: 12px; padding: 8px; gap: 3px; } + .playground-board .playground-view { top: 14px; right: 12px; } + .playground-palette { left: 12px; bottom: 14px; gap: 5px; padding: 7px; } + .playground-swatch { width: 20px; height: 20px; } + .playground-background-reset { width: 21px; } + .playground-controls { right: 12px; left: auto; bottom: 14px; transform: none; } + .playground-controls button { width: 33px; height: 36px; } + .playground-canvas-hint { bottom: 70px; padding: 9px 12px; font-size: 11px; } +} +@media screen and (max-width: 359px) { + .playground-controls { bottom: 65px; } + .playground-canvas-hint { bottom: 115px; } +} +@media print { .playground-pin-tray, .playground-canvas-hint, .playground-card-pin { display: none; } } +/* Direct canvas links should not draw a full-width programmatic focus ring. */ +:root:not([data-input-modality="keyboard"]) .playground-section:focus { outline: none; } diff --git a/playground.js b/playground.js index f049301..ffee7dc 100644 --- a/playground.js +++ b/playground.js @@ -78,7 +78,7 @@ let zoom = 1; let fit = 1; const compactScreen = matchMedia("(max-width: 767px)"); - let list = compactScreen.matches; + let list = false; let drag = null; let panDrag = null; const camera = { x: 0, y: 0, vx: 0, vy: 0, ready: false }; @@ -97,21 +97,19 @@ function arrange() { if (list || !board.clientWidth) return; fit = Math.max( - 0.8, + 0.25, Math.min( - (board.clientWidth - 12) / world.offsetWidth, - (board.clientHeight - 88) / world.offsetHeight, + (board.clientWidth - 40) / world.offsetWidth, + (board.clientHeight - 160) / world.offsetHeight, 1, ), ); const scale = fit * zoom; if (!camera.ready) { - camera.x = compactScreen.matches - ? -10 - : (board.clientWidth - world.offsetWidth * scale) / 2; + camera.x = (board.clientWidth - world.offsetWidth * scale) / 2; camera.y = Math.max( - 18, - (board.clientHeight - 88 - world.offsetHeight * scale) / 2, + 64, + (board.clientHeight - 100 - world.offsetHeight * scale) / 2, ); camera.ready = true; } @@ -412,6 +410,7 @@ }); function setColor(color) { board.dataset.canvasColor = color; + board.querySelector("[data-playground-background-reset]").disabled = color === "blue"; board .querySelectorAll("[data-playground-color]") .forEach((button) => @@ -421,8 +420,13 @@ ), ); } - if (["blue", "cream", "green", "lilac", "pink"].includes(saved.color)) - setColor(saved.color); + setColor(["blue", "cream", "peach", "green", "lilac", "pink"].includes(saved.color) ? saved.color : "blue"); + board.querySelector("[data-playground-background-reset]").addEventListener("click", () => { + saved.color = "blue"; + setColor("blue"); + save(); + announce("Canvas background reset."); + }); board.querySelectorAll("[data-playground-check]").forEach((input) => { input.checked = saved[input.dataset.playgroundCheck] === true; input.addEventListener("change", () => { @@ -431,6 +435,100 @@ }); }); taskProgress(); + let selectedPin = null; + let pinDrag = null; + let pinGhost = null; + let droppedPin = false; + const pinTools = [...board.querySelectorAll("[data-pin-colour]")]; + function cancelPinDrag() { + const held = pinDrag; + pinDrag = null; + pinGhost?.remove(); + pinGhost = null; + if (held) pinTools.forEach(tool => { + if (tool.hasPointerCapture(held.id)) tool.releasePointerCapture(held.id); + }); + selectPin(null); + } + window.addEventListener("blur", cancelPinDrag); + document.addEventListener("playlab:panelchange", cancelPinDrag); + function selectPin(colour) { + selectedPin = colour; + board.classList.toggle("is-pinning", Boolean(colour)); + pinTools.forEach(button => button.setAttribute("aria-pressed", String(button.dataset.pinColour === colour))); + } + function pinCard(card, colour) { + stopMovement(); + card.dataset.pinned = colour; + card.querySelector(".playground-card-pin").hidden = false; + selectPin(null); + announce("Card pinned. Select its pin to unpin it."); + } + for (const card of cards) { + const pin = document.createElement("button"); + pin.type = "button"; + pin.className = "playground-card-pin"; + pin.setAttribute("aria-label", "Unpin this card"); + pin.hidden = true; + pin.addEventListener("click", () => { + delete card.dataset.pinned; + pin.hidden = true; + card.focus({ preventScroll: true }); + announce("Card unpinned."); + }); + card.append(pin); + } + for (const tool of pinTools) { + tool.addEventListener("click", () => { + if (droppedPin) { droppedPin = false; return; } + selectPin(selectedPin === tool.dataset.pinColour ? null : tool.dataset.pinColour); + announce(selectedPin ? "Choose a card to pin. Escape cancels." : "Pin selection cleared."); + }); + tool.addEventListener("pointerdown", event => { + if (event.button !== 0) return; + pinDrag = { id: event.pointerId, x: event.clientX, y: event.clientY, colour: tool.dataset.pinColour }; + tool.setPointerCapture(event.pointerId); + }); + tool.addEventListener("pointermove", event => { + if (!pinDrag || pinDrag.id !== event.pointerId || Math.hypot(event.clientX - pinDrag.x, event.clientY - pinDrag.y) < 4) return; + if (!pinGhost) { + pinGhost = document.createElement("span"); + pinGhost.className = "playground-pin-ghost"; + pinGhost.dataset.pinned = pinDrag.colour; + document.body.append(pinGhost); + } + pinGhost.style.left = `${event.clientX}px`; + pinGhost.style.top = `${event.clientY}px`; + }); + const releasePin = event => { + if (!pinDrag || pinDrag.id !== event.pointerId) return; + if (pinGhost && event.type === "pointerup") { + const card = document.elementFromPoint(event.clientX, event.clientY)?.closest("[data-playground-card]"); + if (card && board.contains(card)) pinCard(card, pinDrag.colour); + droppedPin = true; + } + pinDrag = null; + pinGhost?.remove(); + pinGhost = null; + if (tool.hasPointerCapture(event.pointerId)) tool.releasePointerCapture(event.pointerId); + }; + ["pointerup", "pointercancel", "lostpointercapture"].forEach(type => tool.addEventListener(type, releasePin)); + } + board.addEventListener("pointerdown", event => { + if (!selectedPin || event.target.closest("button, input, textarea, a, select")) return; + const card = event.target.closest("[data-playground-card]"); + if (!card) return; + event.preventDefault(); + event.stopImmediatePropagation(); + pinCard(card, selectedPin); + }, true); + board.addEventListener("keydown", event => { + if (event.key === "Escape") cancelPinDrag(); + if (selectedPin && ["Enter", " "].includes(event.key) && event.target.matches("[data-playground-card]")) { + event.preventDefault(); + pinCard(event.target, selectedPin); + } + }); board.querySelectorAll("[data-playground-color]").forEach((button) => button.addEventListener("click", () => { saved.color = button.dataset.playgroundColor; @@ -477,16 +575,20 @@ zoom = 1; camera.ready = false; for (const [card, position] of positions) { + delete card.dataset.pinned; + card.querySelector(".playground-card-pin").hidden = true; position.x = 0; position.y = 0; card.style.zIndex = ""; paint(card); } arrange(); + selectPin(null); announce("Cards and zoom reset."); }); function setList(next) { stopMovement(); + cancelPinDrag(); list = next; board.classList.toggle("is-list", list); view.setAttribute("aria-pressed", String(list)); @@ -503,7 +605,7 @@ view.addEventListener("click", () => setList(!list)); compactScreen.addEventListener("change", () => { camera.ready = false; - setList(compactScreen.matches); + setList(list); }); board.addEventListener("pointerdown", (event) => { if ( @@ -603,6 +705,7 @@ card.addEventListener("pointerdown", (event) => { if ( list || + card.dataset.pinned || event.button !== 0 || drag || panDrag || @@ -705,6 +808,7 @@ card.addEventListener("keydown", (event) => { if ( list || + card.dataset.pinned || event.target !== card || !["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes( event.key, @@ -774,6 +878,7 @@ view.hidden = false; board.querySelector(".playground-palette").hidden = false; board.querySelector(".playground-controls").hidden = false; + board.querySelector(".playground-pin-tray").hidden = false; new ResizeObserver(arrange).observe(board); arrange(); updateClock(); diff --git a/scripts/motion-checks.mjs b/scripts/motion-checks.mjs index 767f122..15fe920 100644 --- a/scripts/motion-checks.mjs +++ b/scripts/motion-checks.mjs @@ -61,6 +61,18 @@ export async function canvasMotion(page, load) { const board = page.locator("[data-playground-board]"); const world = page.locator(".playground-world"); const card = page.locator(".playground-clock"); + await page.locator('[data-pin-colour="gold"]').click(); + await card.focus(); + await card.press("Enter"); + assert.equal(await card.getAttribute("data-pinned"), "gold"); + const pinnedX = (await card.boundingBox()).x; + await card.press("ArrowRight"); + assert.equal((await card.boundingBox()).x, pinnedX, "Pinned cards stay in place"); + await card.locator(".playground-card-pin").click(); + assert.equal(await card.getAttribute("data-pinned"), null); + await page.locator('[data-playground-color="peach"]').click(); + await page.locator("[data-playground-background-reset]").click(); + assert.equal(await board.getAttribute("data-canvas-color"), "blue"); await board.scrollIntoViewIfNeeded(); const zoomLabel = page.locator("[data-playground-zoom-label]"); const wheelBox = await board.boundingBox(); diff --git a/scripts/playground.mjs b/scripts/playground.mjs index f524ded..1893c1b 100644 --- a/scripts/playground.mjs +++ b/scripts/playground.mjs @@ -14,8 +14,9 @@ const drag = (name, id) => export function renderPlayground({ profile = {} } = {}) { return `

Play Lab

-
The little idea board

Move things around. Leave a little thought.

-
+
+ +
${widgetTitle("✎", "A little thought")}
Yours to edit.0 / 240
— ${esc((profile.name || "Baivab Sarkar").split(" ")[0])}
${widgetTitle("◷", "A moment of focus")}
05:00one thing at a time

@@ -25,10 +26,11 @@ export function renderPlayground({ profile = {} } = {}) {
${widgetTitle("◒", "Make a little colour")}
Good things
come in colour.
#D1C2F0

${widgetTitle("↗", "From idea to browser")}Markdown Viewer editor and rendered preview

Markdown Viewer

A calmer place to write, preview, and share.

Explore the project
- - + +
Drag to pan Scroll to zoom
+
-

Grab a card and give it a little throw. Drag the empty canvas to explore. Scroll over it to zoom at your pointer; zoom out for more room. Arrow keys move a focused card or pan the canvas. Buttons and inputs work as usual.

+

Grab a card and give it a little throw. Drag the empty canvas to explore. Scroll over it to zoom at your pointer; zoom out for more room. Arrow keys move a focused card or pan the canvas. Buttons and inputs work as usual.

${renderInteractions()}`; } From c5f9010b4639cf8528a4ba0cc7fc914a91aca4ba Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Sun, 13 Sep 2026 00:45:41 +0530 Subject: [PATCH 08/22] fix(play-lab): refine canvas hints pins and palette controls --- docs/design-system.md | 4 ++-- play-lab.html | 4 ++-- playground.css | 6 ++---- playground.js | 33 ++++----------------------------- scripts/motion-checks.mjs | 9 +++++++++ scripts/playground.mjs | 4 ++-- 6 files changed, 21 insertions(+), 39 deletions(-) diff --git a/docs/design-system.md b/docs/design-system.md index 80c5668..2c629e1 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -26,7 +26,7 @@ The six folder tints are #b8f0d8, #d4c9f5, #b8cef5, #f5d4b8, #f2a65a and #c8e6c0 - Work: a centered heading, shared category/search controls, six featured folders and six archive folders. Curated order is preserved until an explicit date sort. Four private-work cards use original illustrations, sans-serif headlines and the article index’s clean art-and-copy layout. - Blog: a left-aligned engineering publication header, featured article, search/filter/sort controls and a card grid with original vector artwork. Local readers use clear sans-serif titles, an author byline, a sidebar section index, a generous text column and code panels. - Project details: large left-aligned title, rounded metadata tray, genuine project imagery or a labeled placeholder, sticky section navigation and next-project link. -- Play Lab: a rounded canvas with an editable sticky note, focus timer, checklist, personal vote, colour mixer, clock and project widget. Drag any non-control surface or use arrow keys; releases carry momentum and bounce softly at the canvas edges. The canvas uses a near-viewport rounded blue frame, six pastel swatches with a separate background reset, a floating pan/zoom hint, centered zoom/reset controls, and three draggable pins. The seven existing widget cards retain their content and visual design. Desktop and mobile start in Canvas view with all cards visible; List view is available for reading. +- Play Lab: a rounded canvas with an editable sticky note, focus timer, checklist, personal vote, colour mixer, clock and project widget. Drag any non-control surface or use arrow keys; releases carry momentum and bounce softly at the canvas edges. The canvas uses a near-viewport rounded blue frame, six pastel swatches with a separate background reset, a floating pan/zoom hint, centered zoom/reset controls, and three draggable pins. The seven existing widget cards retain their content and visual design. Desktop and mobile start in Canvas view with a 12% closer initial card scale and the existing world dimensions; List view is available for reading. The instructions hide on the first canvas click. The palette reset uses a centered SVG icon aligned with the swatches. - 404: a centered message, playable Bug Run panel and return-home link. Mobile uses top identity/menu controls, one-column collections, smaller type, two-column metadata with the third item spanning both, and a compact bottom reading index. Native links and article content remain usable without JavaScript. @@ -35,7 +35,7 @@ Mobile uses top identity/menu controls, one-column collections, smaller type, tw Folder transforms and hover lifts use ease-out and spring-like curves. Pointer movement locally enlarges dots within a soft radius. Word-sized masks protect visible text with one pixel of padding; rounded masks protect painted UI surfaces. Transparent links and layout wrappers do not clear their surrounding area, and hovering controls keeps nearby dots reactive. Normal dots retain uniform opacity through all four edges and corners; only the pointer highlight blends back into the grid over a 225px radius. Pointer falloff blends dot size and opacity; dark mode uses a quieter base and highlight. Hover dot radii peak at 3px in light mode and 2.35px in dark mode; highlight colors and opacity remain unchanged. Touch and reduced-motion devices retain only static dots; rendering stops when idle. Without JavaScript, the page uses the plain canvas color. Soft shadows replace decorative borders. Mouse focus avoids oversized rings while keyboard focus remains visible. The game starts only on input. Reduced-motion styles suppress decorative movement. -The Interactions section of Play Lab contains six working demos: shape switching, selectable spring motion, a tilting/turning studio pass, click celebration, an adjustable spinning petal bloom and a tactile ripple pond with three palettes. A keyboard-accessible segmented control shows exactly one panel at a time, with a short entrance transition. The canvas camera pans independently of cards; wheel scrolling zooms around the pointer from 65% to 165%, while scrolling outside the canvas or in List view scrolls the page. Zooming out exposes usable world space. Dragging and throwing cards preserves momentum, while mobile offers a readable List view alongside its initial Canvas view. Pins can be dragged onto a card or selected and placed with the keyboard; selecting the card pin unpins it. View reset restores positions, zoom, and pins, while background reset only resets the canvas colour. The physics demo integrates a damped spring with drag velocity and three response presets. Celebration has a clear burst and halo. The water demo draws soft, low-opacity pastel wave bands without refraction, caustics or specular reflections, then settles; reduced motion produces a still wave pattern. The Depth card tilts over 600ms and flips over 900ms. Physics keeps its draggable spring motion without a visible coil or anchor ring. Keyboard, touch and reduced-motion feedback remain available. +The Interactions section of Play Lab contains six working demos: shape switching, selectable spring motion, a tilting/turning studio pass, click celebration, an adjustable spinning petal bloom and a tactile ripple pond with three palettes. A keyboard-accessible segmented control shows exactly one panel at a time, with a short entrance transition. The canvas camera pans independently of cards; wheel scrolling zooms around the pointer from 65% to 165%, while scrolling outside the canvas or in List view scrolls the page. Zooming out exposes usable world space. Dragging and throwing cards preserves momentum, while mobile offers a readable List view alongside its initial Canvas view. Pins can only be placed by dragging and dropping onto a card; selecting the card pin unpins it. View reset restores positions, zoom, and pins, while background reset only resets the canvas colour. The physics demo integrates a damped spring with drag velocity and three response presets. Celebration has a clear burst and halo. The water demo draws soft, low-opacity pastel wave bands without refraction, caustics or specular reflections, then settles; reduced motion produces a still wave pattern. The Depth card tilts over 600ms and flips over 900ms. Physics keeps its draggable spring motion without a visible coil or anchor ring. Keyboard, touch and reduced-motion feedback remain available. ## Self-hosted font assets and licenses diff --git a/play-lab.html b/play-lab.html index cf14969..677758d 100644 --- a/play-lab.html +++ b/play-lab.html @@ -38,7 +38,7 @@

Play Lab

- +

A little thought

Yours to edit.0 / 240
— Baivab
@@ -49,7 +49,7 @@

Make a little colour

Good things
come in colour.
#D1C2F0

- +
Drag to pan Scroll to zoom
diff --git a/playground.css b/playground.css index 25c1608..0ec6fa3 100644 --- a/playground.css +++ b/playground.css @@ -1440,7 +1440,6 @@ .playground-board .playground-view { top: 16px; right: 16px; padding: 11px 16px; font-size: 12px; } .playground-pin-tray { top: 16px; left: 16px; display: flex; gap: 6px; padding: 10px 12px; border-radius: 18px; } .playground-pin-tool { width: 32px; height: 38px; border: 0; background: transparent; padding: 4px; cursor: grab; touch-action: none; border-radius: 8px; } - .playground-pin-tool[aria-pressed="true"] { outline: 2px solid #526575; outline-offset: 2px; } [data-pin-colour="gold"], [data-pinned="gold"] { --pin: #a7791e; --pin-light: #d6b15d; } [data-pin-colour="teal"], [data-pinned="teal"] { --pin: #24768d; --pin-light: #63b0c1; } [data-pin-colour="violet"], [data-pinned="violet"] { --pin: #7951ac; --pin-light: #b591d8; } @@ -1448,11 +1447,10 @@ .playground-card-pin { position: absolute; z-index: 5; top: -10px; left: calc(50% - 12px); cursor: pointer; } .playground-pin-ghost { position: fixed; z-index: 1000; transform: translate(-50%, -50%); pointer-events: none; } .playground-ready .playground-card[data-pinned] { cursor: default; } - .playground-board.is-pinning .playground-card { cursor: crosshair; } .playground-palette { left: 16px; bottom: 16px; gap: 7px; padding: 8px; border-radius: 24px; } .playground-swatch { width: 23px; height: 23px; } .playground-swatch[aria-pressed="true"] { box-shadow: 0 0 0 2px #fff, 0 0 0 3px #70818a; } - .playground-background-reset { border: 0; background: transparent; color: #737c82; font-size: 19px; width: 24px; height: 24px; border-radius: 50%; } + .playground-background-reset { display: grid; place-items: center; flex: 0 0 auto; padding: 0; margin: 0; line-height: 1; border: 0; background: transparent; color: #737c82; font-size: 19px; width: 24px; height: 24px; border-radius: 50%; } .playground-controls { left: 50%; right: auto; transform: translateX(-50%); bottom: 16px; padding: 0; border-radius: 14px; overflow: hidden; gap: 0; } .playground-controls button { border-radius: 0; width: 38px; height: 38px; font-size: 22px; } .playground-controls button + button { border-left: 1px solid rgb(80 90 100 / .1); } @@ -1477,7 +1475,7 @@ .playground-board .playground-view { top: 14px; right: 12px; } .playground-palette { left: 12px; bottom: 14px; gap: 5px; padding: 7px; } .playground-swatch { width: 20px; height: 20px; } - .playground-background-reset { width: 21px; } + .playground-background-reset { width: 20px; height: 20px; } .playground-controls { right: 12px; left: auto; bottom: 14px; transform: none; } .playground-controls button { width: 33px; height: 36px; } .playground-canvas-hint { bottom: 70px; padding: 9px 12px; font-size: 11px; } diff --git a/playground.js b/playground.js index ffee7dc..b76d1e9 100644 --- a/playground.js +++ b/playground.js @@ -96,7 +96,7 @@ } function arrange() { if (list || !board.clientWidth) return; - fit = Math.max( + fit = 1.12 * Math.max( 0.25, Math.min( (board.clientWidth - 40) / world.offsetWidth, @@ -435,10 +435,8 @@ }); }); taskProgress(); - let selectedPin = null; let pinDrag = null; let pinGhost = null; - let droppedPin = false; const pinTools = [...board.querySelectorAll("[data-pin-colour]")]; function cancelPinDrag() { const held = pinDrag; @@ -448,20 +446,13 @@ if (held) pinTools.forEach(tool => { if (tool.hasPointerCapture(held.id)) tool.releasePointerCapture(held.id); }); - selectPin(null); } window.addEventListener("blur", cancelPinDrag); document.addEventListener("playlab:panelchange", cancelPinDrag); - function selectPin(colour) { - selectedPin = colour; - board.classList.toggle("is-pinning", Boolean(colour)); - pinTools.forEach(button => button.setAttribute("aria-pressed", String(button.dataset.pinColour === colour))); - } function pinCard(card, colour) { stopMovement(); card.dataset.pinned = colour; card.querySelector(".playground-card-pin").hidden = false; - selectPin(null); announce("Card pinned. Select its pin to unpin it."); } for (const card of cards) { @@ -479,11 +470,6 @@ card.append(pin); } for (const tool of pinTools) { - tool.addEventListener("click", () => { - if (droppedPin) { droppedPin = false; return; } - selectPin(selectedPin === tool.dataset.pinColour ? null : tool.dataset.pinColour); - announce(selectedPin ? "Choose a card to pin. Escape cancels." : "Pin selection cleared."); - }); tool.addEventListener("pointerdown", event => { if (event.button !== 0) return; pinDrag = { id: event.pointerId, x: event.clientX, y: event.clientY, colour: tool.dataset.pinColour }; @@ -505,7 +491,6 @@ if (pinGhost && event.type === "pointerup") { const card = document.elementFromPoint(event.clientX, event.clientY)?.closest("[data-playground-card]"); if (card && board.contains(card)) pinCard(card, pinDrag.colour); - droppedPin = true; } pinDrag = null; pinGhost?.remove(); @@ -514,20 +499,11 @@ }; ["pointerup", "pointercancel", "lostpointercapture"].forEach(type => tool.addEventListener(type, releasePin)); } - board.addEventListener("pointerdown", event => { - if (!selectedPin || event.target.closest("button, input, textarea, a, select")) return; - const card = event.target.closest("[data-playground-card]"); - if (!card) return; - event.preventDefault(); - event.stopImmediatePropagation(); - pinCard(card, selectedPin); - }, true); + board.addEventListener("click", () => { + board.querySelector("#playground-instructions").hidden = true; + }, { once: true }); board.addEventListener("keydown", event => { if (event.key === "Escape") cancelPinDrag(); - if (selectedPin && ["Enter", " "].includes(event.key) && event.target.matches("[data-playground-card]")) { - event.preventDefault(); - pinCard(event.target, selectedPin); - } }); board.querySelectorAll("[data-playground-color]").forEach((button) => button.addEventListener("click", () => { @@ -583,7 +559,6 @@ paint(card); } arrange(); - selectPin(null); announce("Cards and zoom reset."); }); function setList(next) { diff --git a/scripts/motion-checks.mjs b/scripts/motion-checks.mjs index 15fe920..6847a1b 100644 --- a/scripts/motion-checks.mjs +++ b/scripts/motion-checks.mjs @@ -61,9 +61,18 @@ export async function canvasMotion(page, load) { const board = page.locator("[data-playground-board]"); const world = page.locator(".playground-world"); const card = page.locator(".playground-clock"); + assert(await page.locator("#playground-instructions").isVisible()); await page.locator('[data-pin-colour="gold"]').click(); + assert(!(await page.locator("#playground-instructions").isVisible())); await card.focus(); await card.press("Enter"); + assert.equal(await card.getAttribute("data-pinned"), null, "Click and Enter cannot place a pin"); + const pinTool = await page.locator('[data-pin-colour="gold"]').boundingBox(); + const pinTarget = await card.boundingBox(); + await page.mouse.move(pinTool.x + pinTool.width / 2, pinTool.y + pinTool.height / 2); + await page.mouse.down(); + await page.mouse.move(pinTarget.x + pinTarget.width / 2, pinTarget.y + 30, { steps: 12 }); + await page.mouse.up(); assert.equal(await card.getAttribute("data-pinned"), "gold"); const pinnedX = (await card.boundingBox()).x; await card.press("ArrowRight"); diff --git a/scripts/playground.mjs b/scripts/playground.mjs index 1893c1b..c5da6ec 100644 --- a/scripts/playground.mjs +++ b/scripts/playground.mjs @@ -15,7 +15,7 @@ export function renderPlayground({ profile = {} } = {}) { return `

Play Lab

- +
${widgetTitle("✎", "A little thought")}
Yours to edit.0 / 240
— ${esc((profile.name || "Baivab Sarkar").split(" ")[0])}
@@ -26,7 +26,7 @@ export function renderPlayground({ profile = {} } = {}) {
${widgetTitle("◒", "Make a little colour")}
Good things
come in colour.
#D1C2F0

${widgetTitle("↗", "From idea to browser")}Markdown Viewer editor and rendered preview

Markdown Viewer

A calmer place to write, preview, and share.

Explore the project
- +
Drag to pan Scroll to zoom
From 94b7c9d1502b4b00cfa97b4c830214b42885877b Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Sun, 13 Sep 2026 00:55:59 +0530 Subject: [PATCH 09/22] fix(play-lab): remove canvas and card focus borders --- playground.css | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/playground.css b/playground.css index 0ec6fa3..f7f79b6 100644 --- a/playground.css +++ b/playground.css @@ -96,9 +96,8 @@ .playground-ready.is-panning { cursor: grabbing; } -.playground-board:focus-visible { - outline: 2px solid #5652b4; - outline-offset: 5px; +.playground-board:focus { + outline: none; } .playground-board[data-canvas-color="cream"] { --canvas-color: #f0eada; @@ -146,9 +145,8 @@ .playground-card.is-moving { will-change: transform; } -.playground-card:focus-visible { - outline: 2px solid #5652b4; - outline-offset: 6px; +.playground-card:focus { + outline: none; } .playground-card.is-dragging { cursor: grabbing; From ceb4b6591633c3e02b26a2bb8e55b8e1ed9cbb77 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Sun, 13 Sep 2026 01:12:12 +0530 Subject: [PATCH 10/22] fix(play-lab): bound canvas and allow zoomed edge dragging --- docs/design-system.md | 4 +-- playground.js | 73 +++++++++++++++++++++++++-------------- scripts/motion-checks.mjs | 54 ++++++++++++++++++++++++++--- scripts/verify.mjs | 4 +-- 4 files changed, 101 insertions(+), 34 deletions(-) diff --git a/docs/design-system.md b/docs/design-system.md index 2c629e1..f9ef10c 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -26,7 +26,7 @@ The six folder tints are #b8f0d8, #d4c9f5, #b8cef5, #f5d4b8, #f2a65a and #c8e6c0 - Work: a centered heading, shared category/search controls, six featured folders and six archive folders. Curated order is preserved until an explicit date sort. Four private-work cards use original illustrations, sans-serif headlines and the article index’s clean art-and-copy layout. - Blog: a left-aligned engineering publication header, featured article, search/filter/sort controls and a card grid with original vector artwork. Local readers use clear sans-serif titles, an author byline, a sidebar section index, a generous text column and code panels. - Project details: large left-aligned title, rounded metadata tray, genuine project imagery or a labeled placeholder, sticky section navigation and next-project link. -- Play Lab: a rounded canvas with an editable sticky note, focus timer, checklist, personal vote, colour mixer, clock and project widget. Drag any non-control surface or use arrow keys; releases carry momentum and bounce softly at the canvas edges. The canvas uses a near-viewport rounded blue frame, six pastel swatches with a separate background reset, a floating pan/zoom hint, centered zoom/reset controls, and three draggable pins. The seven existing widget cards retain their content and visual design. Desktop and mobile start in Canvas view with a 12% closer initial card scale and the existing world dimensions; List view is available for reading. The instructions hide on the first canvas click. The palette reset uses a centered SVG icon aligned with the swatches. +- Play Lab: a rounded canvas with an editable sticky note, focus timer, checklist, personal vote, colour mixer, clock and project widget. Drag any non-control surface or use arrow keys; releases carry momentum and bounce softly at the canvas edges. The canvas uses a near-viewport rounded blue frame, six pastel swatches with a separate background reset, a floating pan/zoom hint, centered zoom/reset controls, and three draggable pins. The seven existing widget cards retain their content and visual design. Desktop and mobile start in Canvas view with a 12% closer initial card scale and the existing world dimensions; List view is available for reading. The instructions hide on the first canvas activity, including pointer movement, dragging, scrolling, zooming, typing or keyboard navigation. The palette reset uses a centered SVG icon aligned with the swatches. - 404: a centered message, playable Bug Run panel and return-home link. Mobile uses top identity/menu controls, one-column collections, smaller type, two-column metadata with the third item spanning both, and a compact bottom reading index. Native links and article content remain usable without JavaScript. @@ -35,7 +35,7 @@ Mobile uses top identity/menu controls, one-column collections, smaller type, tw Folder transforms and hover lifts use ease-out and spring-like curves. Pointer movement locally enlarges dots within a soft radius. Word-sized masks protect visible text with one pixel of padding; rounded masks protect painted UI surfaces. Transparent links and layout wrappers do not clear their surrounding area, and hovering controls keeps nearby dots reactive. Normal dots retain uniform opacity through all four edges and corners; only the pointer highlight blends back into the grid over a 225px radius. Pointer falloff blends dot size and opacity; dark mode uses a quieter base and highlight. Hover dot radii peak at 3px in light mode and 2.35px in dark mode; highlight colors and opacity remain unchanged. Touch and reduced-motion devices retain only static dots; rendering stops when idle. Without JavaScript, the page uses the plain canvas color. Soft shadows replace decorative borders. Mouse focus avoids oversized rings while keyboard focus remains visible. The game starts only on input. Reduced-motion styles suppress decorative movement. -The Interactions section of Play Lab contains six working demos: shape switching, selectable spring motion, a tilting/turning studio pass, click celebration, an adjustable spinning petal bloom and a tactile ripple pond with three palettes. A keyboard-accessible segmented control shows exactly one panel at a time, with a short entrance transition. The canvas camera pans independently of cards; wheel scrolling zooms around the pointer from 65% to 165%, while scrolling outside the canvas or in List view scrolls the page. Zooming out exposes usable world space. Dragging and throwing cards preserves momentum, while mobile offers a readable List view alongside its initial Canvas view. Pins can only be placed by dragging and dropping onto a card; selecting the card pin unpins it. View reset restores positions, zoom, and pins, while background reset only resets the canvas colour. The physics demo integrates a damped spring with drag velocity and three response presets. Celebration has a clear burst and halo. The water demo draws soft, low-opacity pastel wave bands without refraction, caustics or specular reflections, then settles; reduced motion produces a still wave pattern. The Depth card tilts over 600ms and flips over 900ms. Physics keeps its draggable spring motion without a visible coil or anchor ring. Keyboard, touch and reduced-motion feedback remain available. +The Interactions section of Play Lab contains six working demos: shape switching, selectable spring motion, a tilting/turning studio pass, click celebration, an adjustable spinning petal bloom and a tactile ripple pond with three palettes. A keyboard-accessible segmented control shows exactly one panel at a time, with a short entrance transition. The canvas camera pans independently of cards within finite world boundaries; wheel scrolling zooms around the pointer from 65% to 165%, while scrolling outside the canvas or in List view scrolls the page. Zooming out reveals the same finite world. Card movement uses world bounds rather than the current zoomed viewport. Dragging near a viewport edge pans the camera to reach the rest of the world, stopping at its boundary. Dragging and throwing cards preserves momentum, while mobile offers a readable List view alongside its initial Canvas view. Pins can only be placed by dragging and dropping onto a card; selecting the card pin unpins it. View reset restores positions, zoom, and pins, while background reset only resets the canvas colour. The physics demo integrates a damped spring with drag velocity and three response presets. Celebration has a clear burst and halo. The water demo draws soft, low-opacity pastel wave bands without refraction, caustics or specular reflections, then settles; reduced motion produces a still wave pattern. The Depth card tilts over 600ms and flips over 900ms. Physics keeps its draggable spring motion without a visible coil or anchor ring. Keyboard, touch and reduced-motion feedback remain available. ## Self-hosted font assets and licenses diff --git a/playground.js b/playground.js index b76d1e9..ce1e44d 100644 --- a/playground.js +++ b/playground.js @@ -114,6 +114,10 @@ camera.ready = true; } paintCamera(); + for (const card of cards) { + clampPosition(card); + paint(card); + } label.value = `${Math.round(zoom * 100)}%`; board.querySelector('[data-playground-zoom="out"]').disabled = zoom <= 0.65; @@ -122,6 +126,16 @@ } function paintCamera() { const scale = fit * zoom; + const limit = (value, viewport, size, leading, trailing) => + size <= viewport - leading - trailing + ? leading + (viewport - leading - trailing - size) / 2 + : Math.max(viewport - trailing - size, Math.min(leading, value)); + const x = limit(camera.x, board.clientWidth, world.offsetWidth * scale, 18, 18); + const y = limit(camera.y, board.clientHeight, world.offsetHeight * scale, 64, 80); + if (x !== camera.x) camera.vx = 0; + if (y !== camera.y) camera.vy = 0; + camera.x = x; + camera.y = y; world.style.transform = `translate3d(${camera.x}px, ${camera.y}px, 0) scale(${scale})`; board.style.backgroundPosition = `${camera.x}px ${camera.y}px`; board.style.backgroundSize = `${24 * scale}px ${24 * scale}px`; @@ -131,23 +145,33 @@ card.style.transform = `translate3d(${position.x}px, ${position.y}px, 0) rotate(calc(var(--card-angle) + ${position.tilt}deg)) scale(${1 + position.lift * 0.035})`; } function bounds(card) { - // The usable world is the current camera viewport, not the original card - // arrangement. Zooming out therefore creates real space on every side. - const scale = fit * zoom; + // Card limits belong to the finite world, independently of camera and zoom. const pad = 18; return { - left: (pad - camera.x) / scale - card.offsetLeft, - right: - (board.clientWidth - pad - camera.x) / scale - - card.offsetLeft - - card.offsetWidth, - top: (pad - camera.y) / scale - card.offsetTop, - bottom: - (board.clientHeight - 92 - camera.y) / scale - - card.offsetTop - - card.offsetHeight, + left: pad - card.offsetLeft, + right: world.offsetWidth - pad - card.offsetLeft - card.offsetWidth, + top: pad - card.offsetTop, + bottom: world.offsetHeight - pad - card.offsetTop - card.offsetHeight, }; } + function updateDragTarget() { + const b = bounds(drag.card), scale = fit * zoom; + drag.targetX = Math.max(b.left, Math.min(b.right, + drag.startX + (drag.lastX - drag.x + drag.cameraX - camera.x) / scale)); + drag.targetY = Math.max(b.top, Math.min(b.bottom, + drag.startY + (drag.lastY - drag.y + drag.cameraY - camera.y) / scale)); + } + function panAtDragEdge(dt) { + if (!drag?.moved) return; + const rect = board.getBoundingClientRect(); + const speed = (point, low, high) => point < low + 48 + ? Math.min(1, (low + 48 - point) / 48) + : point > high - 48 ? -Math.min(1, (point - high + 48) / 48) : 0; + camera.x += speed(drag.lastX, rect.left, rect.right) * dt * 0.65; + camera.y += speed(drag.lastY, rect.top + 64, rect.bottom - 80) * dt * 0.65; + paintCamera(); + updateDragTarget(); + } function clampPosition(card) { const p = positions.get(card), b = bounds(card); @@ -165,6 +189,7 @@ const dt = Math.min(32, Math.max(1, now - lastFrame)); lastFrame = now; let moving = false; + panAtDragEdge(dt); if (panDrag) { const ease = 1 - Math.pow(0.42, dt / 16.67); camera.x += (panDrag.targetX - camera.x) * ease; @@ -499,9 +524,12 @@ }; ["pointerup", "pointercancel", "lostpointercapture"].forEach(type => tool.addEventListener(type, releasePin)); } - board.addEventListener("click", () => { + const activityEvents = ["pointerdown", "pointermove", "click", "wheel", "keydown", "input"]; + const hideInstructions = () => { board.querySelector("#playground-instructions").hidden = true; - }, { once: true }); + activityEvents.forEach(type => board.removeEventListener(type, hideInstructions, true)); + }; + activityEvents.forEach(type => board.addEventListener(type, hideInstructions, { capture: true, passive: true })); board.addEventListener("keydown", event => { if (event.key === "Escape") cancelPinDrag(); }); @@ -701,6 +729,8 @@ y: event.clientY, startX: position.x, startY: position.y, + cameraX: camera.x, + cameraY: camera.y, targetX: position.x, targetY: position.y, lastX: event.clientX, @@ -719,16 +749,7 @@ if (!drag || drag.card !== card || drag.id !== event.pointerId) return; const scale = fit * zoom; const now = performance.now(), - dt = Math.max(8, now - drag.time), - b = bounds(card); - drag.targetX = Math.max( - b.left, - Math.min(b.right, drag.startX + (event.clientX - drag.x) / scale), - ); - drag.targetY = Math.max( - b.top, - Math.min(b.bottom, drag.startY + (event.clientY - drag.y) / scale), - ); + dt = Math.max(8, now - drag.time); drag.vx = Math.max( -1.8, Math.min( @@ -746,9 +767,11 @@ drag.lastX = event.clientX; drag.lastY = event.clientY; drag.time = now; + updateDragTarget(); drag.moved ||= Math.hypot(event.clientX - drag.x, event.clientY - drag.y) > 4; if (motion.matches) { + panAtDragEdge(Math.min(dt, 32)); const p = positions.get(card); p.x = drag.targetX; p.y = drag.targetY; diff --git a/scripts/motion-checks.mjs b/scripts/motion-checks.mjs index 6847a1b..f9f8bee 100644 --- a/scripts/motion-checks.mjs +++ b/scripts/motion-checks.mjs @@ -109,14 +109,14 @@ export async function canvasMotion(page, load) { const body = await card.locator("time").boundingBox(); await page.mouse.move(body.x + body.width / 2, body.y + body.height / 2); await page.mouse.down(); - await page.mouse.move(box.x + box.width - 110, body.y + body.height / 2, { + await page.mouse.move(box.x + 110, body.y + body.height / 2, { steps: 16, }); await page.waitForTimeout(160); const held = await card.boundingBox(); assert( - held.x > before.x + 100, - "A card moves into newly exposed space at 65% zoom", + held.x < before.x - 100, + "A card moves across the finite world at 65% zoom", ); await page.mouse.up(); await page.waitForTimeout(1400); @@ -128,10 +128,10 @@ export async function canvasMotion(page, load) { await board.focus(); const previous = await world.getAttribute("style"); await board.press("ArrowRight"); - assert.notEqual( + assert.equal( await world.getAttribute("style"), previous, - "Camera pans independently of the cards", + "Camera stays centered when the finite world fits the viewport", ); await page.locator("[data-playground-reset]").click(); await page.locator("#interactions-tab").click(); @@ -181,3 +181,47 @@ export async function canvasMotion(page, load) { ); await page.emulateMedia({ reducedMotion: "no-preference" }); } + +export async function canvasBounds(page, load) { + for (const type of ["pointerdown", "pointermove", "wheel", "keydown", "input", "click"]) { + await load(page, "/play-lab#canvas"); + await page.reload({ waitUntil: "networkidle" }); + const board = page.locator("[data-playground-board]"); + assert(await page.locator("#playground-instructions").isVisible()); + await board.dispatchEvent(type, type === "wheel" ? { deltaY: -100 } : {}); + assert(!(await page.locator("#playground-instructions").isVisible()), `${type} dismisses instructions`); + } + for (const width of [1440, 390]) { + await page.setViewportSize({ width, height: 1000 }); + await load(page, "/play-lab#canvas"); + await page.reload({ waitUntil: "networkidle" }); + const board = page.locator("[data-playground-board]"); + const world = page.locator(".playground-world"); + for (let step = 0; step < 5; step++) await page.locator('[data-playground-zoom="in"]').click(); + await board.focus(); + for (const key of ["ArrowRight", "ArrowDown", "ArrowLeft", "ArrowUp"]) { + for (let step = 0; step < 25; step++) await board.press(`Shift+${key}`); + const edge = await world.getAttribute("style"); + await board.press(`Shift+${key}`); + assert.equal(await world.getAttribute("style"), edge, `${width}: finite ${key} edge`); + } + if (width === 1440) { + const note = page.locator(".playground-note"); + const heading = await note.locator(".widget-heading").boundingBox(); + const rect = await board.boundingBox(); + const initial = await world.evaluate(el => new DOMMatrix(getComputedStyle(el).transform).m41); + await page.mouse.move(heading.x + heading.width / 2, heading.y + heading.height / 2); + await page.mouse.down(); + await page.mouse.move(rect.x + rect.width - 3, heading.y + heading.height / 2, { steps: 12 }); + await page.waitForTimeout(1800); + assert(await world.evaluate(el => new DOMMatrix(getComputedStyle(el).transform).m41) < initial - 100, + "Dragging at a zoomed edge pans across the full world"); + await page.mouse.up(); + await page.waitForTimeout(1400); + assert(await note.evaluate(el => { + const x = el.offsetLeft + new DOMMatrix(getComputedStyle(el).transform).m41; + return x >= 17 && x + el.offsetWidth <= el.parentElement.offsetWidth - 17; + }), "Thrown card remains inside the world"); + } + } +} diff --git a/scripts/verify.mjs b/scripts/verify.mjs index c576313..72d5ab9 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -7,7 +7,7 @@ import { startServer } from "./serve.mjs"; import { loadLocalArticles, mergeArticles } from "./local-articles.mjs"; import { projectCollections } from "./project-selection.mjs"; import { refinementChecks } from "./refinement-checks.mjs"; -import { conversationTiming, canvasMotion } from "./motion-checks.mjs"; +import { conversationTiming, canvasMotion, canvasBounds } from "./motion-checks.mjs"; import { dotChecks } from "./dot-checks.mjs"; const args = process.argv.slice(2); @@ -1456,7 +1456,7 @@ try { ], [ "zoomed canvas, tab isolation and water feedback", - () => canvasMotion(page, load), + async () => { await canvasMotion(page, load); await canvasBounds(page, load); }, ], ["game start, pause, reset and keyboard", () => game(page)], ["clipboard success and denial", () => clipboard(browser)], From 80a3895adfb530926c8ddd5ead0149bd0ce4d131 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Sun, 13 Sep 2026 01:30:34 +0530 Subject: [PATCH 11/22] fix(play-lab): extend card movement to the full board --- docs/design-system.md | 2 +- playground.js | 29 +++++++++++++++++------------ scripts/motion-checks.mjs | 20 +++++++++++++++++++- 3 files changed, 37 insertions(+), 14 deletions(-) diff --git a/docs/design-system.md b/docs/design-system.md index f9ef10c..2c46590 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -35,7 +35,7 @@ Mobile uses top identity/menu controls, one-column collections, smaller type, tw Folder transforms and hover lifts use ease-out and spring-like curves. Pointer movement locally enlarges dots within a soft radius. Word-sized masks protect visible text with one pixel of padding; rounded masks protect painted UI surfaces. Transparent links and layout wrappers do not clear their surrounding area, and hovering controls keeps nearby dots reactive. Normal dots retain uniform opacity through all four edges and corners; only the pointer highlight blends back into the grid over a 225px radius. Pointer falloff blends dot size and opacity; dark mode uses a quieter base and highlight. Hover dot radii peak at 3px in light mode and 2.35px in dark mode; highlight colors and opacity remain unchanged. Touch and reduced-motion devices retain only static dots; rendering stops when idle. Without JavaScript, the page uses the plain canvas color. Soft shadows replace decorative borders. Mouse focus avoids oversized rings while keyboard focus remains visible. The game starts only on input. Reduced-motion styles suppress decorative movement. -The Interactions section of Play Lab contains six working demos: shape switching, selectable spring motion, a tilting/turning studio pass, click celebration, an adjustable spinning petal bloom and a tactile ripple pond with three palettes. A keyboard-accessible segmented control shows exactly one panel at a time, with a short entrance transition. The canvas camera pans independently of cards within finite world boundaries; wheel scrolling zooms around the pointer from 65% to 165%, while scrolling outside the canvas or in List view scrolls the page. Zooming out reveals the same finite world. Card movement uses world bounds rather than the current zoomed viewport. Dragging near a viewport edge pans the camera to reach the rest of the world, stopping at its boundary. Dragging and throwing cards preserves momentum, while mobile offers a readable List view alongside its initial Canvas view. Pins can only be placed by dragging and dropping onto a card; selecting the card pin unpins it. View reset restores positions, zoom, and pins, while background reset only resets the canvas colour. The physics demo integrates a damped spring with drag velocity and three response presets. Celebration has a clear burst and halo. The water demo draws soft, low-opacity pastel wave bands without refraction, caustics or specular reflections, then settles; reduced motion produces a still wave pattern. The Depth card tilts over 600ms and flips over 900ms. Physics keeps its draggable spring motion without a visible coil or anchor ring. Keyboard, touch and reduced-motion feedback remain available. +The Interactions section of Play Lab contains six working demos: shape switching, selectable spring motion, a tilting/turning studio pass, click celebration, an adjustable spinning petal bloom and a tactile ripple pond with three palettes. A keyboard-accessible segmented control shows exactly one panel at a time, with a short entrance transition. The canvas camera pans independently of cards within finite world boundaries; wheel scrolling zooms around the pointer from 65% to 165%, while scrolling outside the canvas or in List view scrolls the page. Zooming out reveals the same finite world. The card movement area matches the full board at 100% zoom, with an 18px edge inset, while preserving the original layout and card scale. Those bounds remain fixed in world coordinates when zooming rather than shrinking to the current viewport. Dragging near a viewport edge pans the camera to reach the rest of the world, stopping at its boundary. Dragging and throwing cards preserves momentum, while mobile offers a readable List view alongside its initial Canvas view. Pins can only be placed by dragging and dropping onto a card; selecting the card pin unpins it. View reset restores positions, zoom, and pins, while background reset only resets the canvas colour. The physics demo integrates a damped spring with drag velocity and three response presets. Celebration has a clear burst and halo. The water demo draws soft, low-opacity pastel wave bands without refraction, caustics or specular reflections, then settles; reduced motion produces a still wave pattern. The Depth card tilts over 600ms and flips over 900ms. Physics keeps its draggable spring motion without a visible coil or anchor ring. Keyboard, touch and reduced-motion feedback remain available. ## Self-hosted font assets and licenses diff --git a/playground.js b/playground.js index ce1e44d..c8a8bd5 100644 --- a/playground.js +++ b/playground.js @@ -82,6 +82,7 @@ let drag = null; let panDrag = null; const camera = { x: 0, y: 0, vx: 0, vy: 0, ready: false }; + const canvasArea = { left: 0, top: 0, width: 0, height: 0 }; let layer = 2; let saved; try { @@ -105,12 +106,16 @@ ), ); const scale = fit * zoom; + // Keep the existing card layout/scale, but give it the full board as its + // movement area at 100%. Zoom changes the view, never these world limits. + canvasArea.width = board.clientWidth / fit; + canvasArea.height = board.clientHeight / fit; + canvasArea.left = (world.offsetWidth - canvasArea.width) / 2; + canvasArea.top = -Math.max(64, + (board.clientHeight - 100 - world.offsetHeight * fit) / 2) / fit; if (!camera.ready) { - camera.x = (board.clientWidth - world.offsetWidth * scale) / 2; - camera.y = Math.max( - 64, - (board.clientHeight - 100 - world.offsetHeight * scale) / 2, - ); + camera.x = -canvasArea.left * scale; + camera.y = -canvasArea.top * scale; camera.ready = true; } paintCamera(); @@ -130,8 +135,8 @@ size <= viewport - leading - trailing ? leading + (viewport - leading - trailing - size) / 2 : Math.max(viewport - trailing - size, Math.min(leading, value)); - const x = limit(camera.x, board.clientWidth, world.offsetWidth * scale, 18, 18); - const y = limit(camera.y, board.clientHeight, world.offsetHeight * scale, 64, 80); + const x = limit(camera.x + canvasArea.left * scale, board.clientWidth, canvasArea.width * scale, 0, 0) - canvasArea.left * scale; + const y = limit(camera.y + canvasArea.top * scale, board.clientHeight, canvasArea.height * scale, 0, 0) - canvasArea.top * scale; if (x !== camera.x) camera.vx = 0; if (y !== camera.y) camera.vy = 0; camera.x = x; @@ -146,12 +151,12 @@ } function bounds(card) { // Card limits belong to the finite world, independently of camera and zoom. - const pad = 18; + const pad = 18 / fit; return { - left: pad - card.offsetLeft, - right: world.offsetWidth - pad - card.offsetLeft - card.offsetWidth, - top: pad - card.offsetTop, - bottom: world.offsetHeight - pad - card.offsetTop - card.offsetHeight, + left: canvasArea.left + pad - card.offsetLeft, + right: canvasArea.left + canvasArea.width - pad - card.offsetLeft - card.offsetWidth, + top: canvasArea.top + pad - card.offsetTop, + bottom: canvasArea.top + canvasArea.height - pad - card.offsetTop - card.offsetHeight, }; } function updateDragTarget() { diff --git a/scripts/motion-checks.mjs b/scripts/motion-checks.mjs index f9f8bee..0b88e24 100644 --- a/scripts/motion-checks.mjs +++ b/scripts/motion-checks.mjs @@ -197,6 +197,20 @@ export async function canvasBounds(page, load) { await page.reload({ waitUntil: "networkidle" }); const board = page.locator("[data-playground-board]"); const world = page.locator(".playground-world"); + const edgeCard = page.locator(".playground-note"); + for (const direction of ["ArrowRight", "ArrowLeft"]) { + await edgeCard.focus(); + for (let step = 0; step < 60; step++) await edgeCard.press(`Shift+${direction}`); + const edge = await edgeCard.evaluate((el, direction) => { + const world = el.parentElement; + const camera = new DOMMatrix(getComputedStyle(world).transform); + const card = new DOMMatrix(getComputedStyle(el).transform); + const x = camera.m41 + (el.offsetLeft + card.m41) * camera.a; + return direction === "ArrowLeft" ? x : world.parentElement.clientWidth - x - el.offsetWidth * camera.a; + }, direction); + assert(Math.abs(edge - 18) < 1, `${width}: card reaches the board's ${direction} edge`); + } + await page.locator("[data-playground-reset]").click(); for (let step = 0; step < 5; step++) await page.locator('[data-playground-zoom="in"]').click(); await board.focus(); for (const key of ["ArrowRight", "ArrowDown", "ArrowLeft", "ArrowUp"]) { @@ -220,7 +234,11 @@ export async function canvasBounds(page, load) { await page.waitForTimeout(1400); assert(await note.evaluate(el => { const x = el.offsetLeft + new DOMMatrix(getComputedStyle(el).transform).m41; - return x >= 17 && x + el.offsetWidth <= el.parentElement.offsetWidth - 17; + const board = el.closest("[data-playground-board]"); + const zoom = parseInt(board.querySelector("[data-playground-zoom-label]").value) / 100; + const fit = new DOMMatrix(getComputedStyle(el.parentElement).transform).a / zoom; + const left = (el.parentElement.offsetWidth - board.clientWidth / fit) / 2; + return x >= left + 17 / fit && x + el.offsetWidth <= left + (board.clientWidth - 17) / fit; }), "Thrown card remains inside the world"); } } From 29a8f212348ea4c40895844710a9621e89e66953 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Sun, 13 Sep 2026 10:59:41 +0530 Subject: [PATCH 12/22] fix(play-lab): use full world coordinates and spaced random cards --- docs/design-system.md | 4 +- playground.css | 17 +++++ playground.js | 105 ++++++++++++++++++++++-------- scripts/motion-checks.mjs | 133 ++++++++++++++++++++++++++------------ 4 files changed, 186 insertions(+), 73 deletions(-) diff --git a/docs/design-system.md b/docs/design-system.md index 2c46590..b03b067 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -26,7 +26,7 @@ The six folder tints are #b8f0d8, #d4c9f5, #b8cef5, #f5d4b8, #f2a65a and #c8e6c0 - Work: a centered heading, shared category/search controls, six featured folders and six archive folders. Curated order is preserved until an explicit date sort. Four private-work cards use original illustrations, sans-serif headlines and the article index’s clean art-and-copy layout. - Blog: a left-aligned engineering publication header, featured article, search/filter/sort controls and a card grid with original vector artwork. Local readers use clear sans-serif titles, an author byline, a sidebar section index, a generous text column and code panels. - Project details: large left-aligned title, rounded metadata tray, genuine project imagery or a labeled placeholder, sticky section navigation and next-project link. -- Play Lab: a rounded canvas with an editable sticky note, focus timer, checklist, personal vote, colour mixer, clock and project widget. Drag any non-control surface or use arrow keys; releases carry momentum and bounce softly at the canvas edges. The canvas uses a near-viewport rounded blue frame, six pastel swatches with a separate background reset, a floating pan/zoom hint, centered zoom/reset controls, and three draggable pins. The seven existing widget cards retain their content and visual design. Desktop and mobile start in Canvas view with a 12% closer initial card scale and the existing world dimensions; List view is available for reading. The instructions hide on the first canvas activity, including pointer movement, dragging, scrolling, zooming, typing or keyboard navigation. The palette reset uses a centered SVG icon aligned with the swatches. +- Play Lab: a rounded canvas with an editable sticky note, focus timer, checklist, personal vote, colour mixer, clock and project widget. Drag any non-control surface or use arrow keys; releases carry momentum and bounce softly at the canvas edges. The canvas uses a near-viewport rounded blue frame, six pastel swatches with a separate background reset, a floating pan/zoom hint, centered zoom/reset controls, and three draggable pins. The seven existing widget cards retain their content and visual design. Desktop and mobile start in Canvas view with the closer card scale. A fresh visit shuffles cards into spaced slots with varied offsets and angles; List view is available for reading. The instructions hide on the first canvas activity, including pointer movement, dragging, scrolling, zooming, typing or keyboard navigation. The palette reset uses a centered SVG icon aligned with the swatches. - 404: a centered message, playable Bug Run panel and return-home link. Mobile uses top identity/menu controls, one-column collections, smaller type, two-column metadata with the third item spanning both, and a compact bottom reading index. Native links and article content remain usable without JavaScript. @@ -35,7 +35,7 @@ Mobile uses top identity/menu controls, one-column collections, smaller type, tw Folder transforms and hover lifts use ease-out and spring-like curves. Pointer movement locally enlarges dots within a soft radius. Word-sized masks protect visible text with one pixel of padding; rounded masks protect painted UI surfaces. Transparent links and layout wrappers do not clear their surrounding area, and hovering controls keeps nearby dots reactive. Normal dots retain uniform opacity through all four edges and corners; only the pointer highlight blends back into the grid over a 225px radius. Pointer falloff blends dot size and opacity; dark mode uses a quieter base and highlight. Hover dot radii peak at 3px in light mode and 2.35px in dark mode; highlight colors and opacity remain unchanged. Touch and reduced-motion devices retain only static dots; rendering stops when idle. Without JavaScript, the page uses the plain canvas color. Soft shadows replace decorative borders. Mouse focus avoids oversized rings while keyboard focus remains visible. The game starts only on input. Reduced-motion styles suppress decorative movement. -The Interactions section of Play Lab contains six working demos: shape switching, selectable spring motion, a tilting/turning studio pass, click celebration, an adjustable spinning petal bloom and a tactile ripple pond with three palettes. A keyboard-accessible segmented control shows exactly one panel at a time, with a short entrance transition. The canvas camera pans independently of cards within finite world boundaries; wheel scrolling zooms around the pointer from 65% to 165%, while scrolling outside the canvas or in List view scrolls the page. Zooming out reveals the same finite world. The card movement area matches the full board at 100% zoom, with an 18px edge inset, while preserving the original layout and card scale. Those bounds remain fixed in world coordinates when zooming rather than shrinking to the current viewport. Dragging near a viewport edge pans the camera to reach the rest of the world, stopping at its boundary. Dragging and throwing cards preserves momentum, while mobile offers a readable List view alongside its initial Canvas view. Pins can only be placed by dragging and dropping onto a card; selecting the card pin unpins it. View reset restores positions, zoom, and pins, while background reset only resets the canvas colour. The physics demo integrates a damped spring with drag velocity and three response presets. Celebration has a clear burst and halo. The water demo draws soft, low-opacity pastel wave bands without refraction, caustics or specular reflections, then settles; reduced motion produces a still wave pattern. The Depth card tilts over 600ms and flips over 900ms. Physics keeps its draggable spring motion without a visible coil or anchor ring. Keyboard, touch and reduced-motion feedback remain available. +The Interactions section of Play Lab contains six working demos: shape switching, selectable spring motion, a tilting/turning studio pass, click celebration, an adjustable spinning petal bloom and a tactile ripple pond with three palettes. A keyboard-accessible segmented control shows exactly one panel at a time, with a short entrance transition. The canvas camera pans independently of cards within finite world boundaries; wheel scrolling zooms around the pointer from 65% to 165%, while scrolling outside the canvas or in List view scrolls the page. Zooming out reveals the same finite world. The finite world is established for the responsive layout to include all space revealed at minimum zoom. Card positions, drag offsets, and limits use that world coordinate system independently of camera translation and current zoom. Boundaries include the rotated/lifted card footprint. Panning changes only the camera; card dragging changes only the selected card, with edge panning to reach distant space. Text/image selection is suppressed while dragging. Dragging near a viewport edge pans the camera to reach the rest of the world, stopping at its boundary. Dragging and throwing cards preserves momentum, while mobile offers a readable List view alongside its initial Canvas view. Pins can only be placed by dragging and dropping onto a card; selecting the card pin unpins it. View reset restores the initial random positions, zoom, and pins, while background reset only resets the canvas colour. The physics demo integrates a damped spring with drag velocity and three response presets. Celebration has a clear burst and halo. The water demo draws soft, low-opacity pastel wave bands without refraction, caustics or specular reflections, then settles; reduced motion produces a still wave pattern. The Depth card tilts over 600ms and flips over 900ms. Physics keeps its draggable spring motion without a visible coil or anchor ring. Keyboard, touch and reduced-motion feedback remain available. ## Self-hosted font assets and licenses diff --git a/playground.css b/playground.css index f7f79b6..f5a246f 100644 --- a/playground.css +++ b/playground.css @@ -1485,3 +1485,20 @@ @media print { .playground-pin-tray, .playground-canvas-hint, .playground-card-pin { display: none; } } /* Direct canvas links should not draw a full-width programmatic focus ring. */ :root:not([data-input-modality="keyboard"]) .playground-section:focus { outline: none; } + +@media screen { + .playground-ready:not(.is-list) { overflow: clip; } + .playground-ready:not(.is-list) .playground-world { + width: var(--world-width, 1800px); + height: var(--world-height, 1400px); + } + .playground-board.is-panning, + .playground-board:has(.playground-card.is-dragging) { + user-select: none; + -webkit-user-select: none; + } + .playground-board:has(.playground-card.is-dragging) :is(input, textarea, [contenteditable]) { + user-select: none; + -webkit-user-select: none; + } +} diff --git a/playground.js b/playground.js index c8a8bd5..23cce23 100644 --- a/playground.js +++ b/playground.js @@ -70,7 +70,7 @@ const positions = new Map( cards.map((card) => [ card, - { x: 0, y: 0, vx: 0, vy: 0, lift: 0, tilt: 0 }, + { x: 0, y: 0, homeX: 0, homeY: 0, vx: 0, vy: 0, lift: 0, tilt: 0, angle: 0 }, ]), ); let frame = 0; @@ -82,7 +82,7 @@ let drag = null; let panDrag = null; const camera = { x: 0, y: 0, vx: 0, vy: 0, ready: false }; - const canvasArea = { left: 0, top: 0, width: 0, height: 0 }; + const canvasArea = { width: 0, height: 0, compact: null }; let layer = 2; let saved; try { @@ -97,25 +97,32 @@ } function arrange() { if (list || !board.clientWidth) return; - fit = 1.12 * Math.max( - 0.25, - Math.min( - (board.clientWidth - 40) / world.offsetWidth, - (board.clientHeight - 160) / world.offsetHeight, - 1, - ), - ); + const newLayout = canvasArea.compact !== compactScreen.matches; + if (newLayout) { + stopMovement(); + canvasArea.compact = compactScreen.matches; + const layoutWidth = canvasArea.compact ? 660 : 1200; + const layoutHeight = canvasArea.compact ? 1440 : 780; + const initialScale = 1.12 * Math.max(0.25, Math.min( + (board.clientWidth - 40) / layoutWidth, + (board.clientHeight - 160) / layoutHeight, 1)); + // Establish the finite workspace once per responsive layout. The full + // area visible at minimum zoom is usable, including beyond the start view. + canvasArea.width = board.clientWidth / (initialScale * 0.65); + canvasArea.height = board.clientHeight / (initialScale * 0.65); + world.style.setProperty("--world-width", `${canvasArea.width}px`); + world.style.setProperty("--world-height", `${canvasArea.height}px`); + scatterCards(); + camera.ready = false; + } + // The minimum zoom covers the board. All space revealed by zooming out + // belongs to this same finite world; card bounds never depend on the view. + fit = Math.max(board.clientWidth / canvasArea.width, + board.clientHeight / canvasArea.height) / 0.65; const scale = fit * zoom; - // Keep the existing card layout/scale, but give it the full board as its - // movement area at 100%. Zoom changes the view, never these world limits. - canvasArea.width = board.clientWidth / fit; - canvasArea.height = board.clientHeight / fit; - canvasArea.left = (world.offsetWidth - canvasArea.width) / 2; - canvasArea.top = -Math.max(64, - (board.clientHeight - 100 - world.offsetHeight * fit) / 2) / fit; if (!camera.ready) { - camera.x = -canvasArea.left * scale; - camera.y = -canvasArea.top * scale; + camera.x = (board.clientWidth - canvasArea.width * scale) / 2; + camera.y = (board.clientHeight - canvasArea.height * scale) / 2; camera.ready = true; } paintCamera(); @@ -135,8 +142,8 @@ size <= viewport - leading - trailing ? leading + (viewport - leading - trailing - size) / 2 : Math.max(viewport - trailing - size, Math.min(leading, value)); - const x = limit(camera.x + canvasArea.left * scale, board.clientWidth, canvasArea.width * scale, 0, 0) - canvasArea.left * scale; - const y = limit(camera.y + canvasArea.top * scale, board.clientHeight, canvasArea.height * scale, 0, 0) - canvasArea.top * scale; + const x = limit(camera.x, board.clientWidth, canvasArea.width * scale, 0, 0); + const y = limit(camera.y, board.clientHeight, canvasArea.height * scale, 0, 0); if (x !== camera.x) camera.vx = 0; if (y !== camera.y) camera.vy = 0; camera.x = x; @@ -151,14 +158,44 @@ } function bounds(card) { // Card limits belong to the finite world, independently of camera and zoom. - const pad = 18 / fit; + const p = positions.get(card); + const angle = (Math.abs(p.angle) + 7) * Math.PI / 180; + // Reserve the maximum rotation/lift footprint, including during a throw. + const padX = 18 + Math.max(0, (1.035 * (card.offsetWidth * Math.cos(angle) + card.offsetHeight * Math.sin(angle)) - card.offsetWidth) / 2); + const padY = 18 + Math.max(0, (1.035 * (card.offsetHeight * Math.cos(angle) + card.offsetWidth * Math.sin(angle)) - card.offsetHeight) / 2); return { - left: canvasArea.left + pad - card.offsetLeft, - right: canvasArea.left + canvasArea.width - pad - card.offsetLeft - card.offsetWidth, - top: canvasArea.top + pad - card.offsetTop, - bottom: canvasArea.top + canvasArea.height - pad - card.offsetTop - card.offsetHeight, + left: padX - card.offsetLeft, + right: canvasArea.width - padX - card.offsetLeft - card.offsetWidth, + top: padY - card.offsetTop, + bottom: canvasArea.height - padY - card.offsetTop - card.offsetHeight, }; } + function scatterCards() { + const layoutWidth = canvasArea.width * 0.65, layoutHeight = canvasArea.height * 0.65; + const widest = Math.max(...cards.map(card => card.offsetWidth)) + 48; + const columns = Math.max(2, Math.min(canvasArea.compact ? 2 : 4, Math.floor(layoutWidth / widest))); + const rows = Math.ceil(cards.length / columns); + const slots = Array.from({ length: rows * columns }, (_, index) => index); + for (let i = slots.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [slots[i], slots[j]] = [slots[j], slots[i]]; + } + const originX = (canvasArea.width - layoutWidth) / 2, originY = (canvasArea.height - layoutHeight) / 2; + const cellWidth = layoutWidth / columns, cellHeight = layoutHeight / rows; + cards.forEach((card, index) => { + const p = positions.get(card), slot = slots[index]; + p.angle = Math.random() * 10 - 5; + card.style.setProperty("--card-angle", `${p.angle}deg`); + const freeX = Math.max(0, cellWidth - card.offsetWidth - 48); + const freeY = Math.max(0, cellHeight - card.offsetHeight - 48); + p.x = originX + (slot % columns) * cellWidth + 24 + Math.random() * freeX - card.offsetLeft; + p.y = originY + Math.floor(slot / columns) * cellHeight + 24 + Math.random() * freeY - card.offsetTop; + clampPosition(card); + p.homeX = p.x; + p.homeY = p.y; + paint(card); + }); + } function updateDragTarget() { const b = bounds(drag.card), scale = fit * zoom; drag.targetX = Math.max(b.left, Math.min(b.right, @@ -535,6 +572,16 @@ activityEvents.forEach(type => board.removeEventListener(type, hideInstructions, true)); }; activityEvents.forEach(type => board.addEventListener(type, hideInstructions, { capture: true, passive: true })); + board.addEventListener("focusin", event => { + const card = event.target.closest("[data-playground-card]"); + if (list || drag || !card || !event.target.matches(":focus-visible")) return; + const rect = card.getBoundingClientRect(), viewport = board.getBoundingClientRect(); + if (rect.left < viewport.left + 18) camera.x += viewport.left + 18 - rect.left; + else if (rect.right > viewport.right - 18) camera.x -= rect.right - viewport.right + 18; + if (rect.top < viewport.top + 70) camera.y += viewport.top + 70 - rect.top; + else if (rect.bottom > viewport.bottom - 80) camera.y -= rect.bottom - viewport.bottom + 80; + paintCamera(); + }); board.addEventListener("keydown", event => { if (event.key === "Escape") cancelPinDrag(); }); @@ -586,8 +633,8 @@ for (const [card, position] of positions) { delete card.dataset.pinned; card.querySelector(".playground-card-pin").hidden = true; - position.x = 0; - position.y = 0; + position.x = position.homeX; + position.y = position.homeY; card.style.zIndex = ""; paint(card); } @@ -625,6 +672,7 @@ return; event.preventDefault(); stopMovement(); + window.getSelection()?.removeAllRanges(); board.focus({ preventScroll: true }); panDrag = { id: event.pointerId, @@ -723,6 +771,7 @@ ) return; event.preventDefault(); + window.getSelection()?.removeAllRanges(); camera.vx = camera.vy = 0; card.focus({ preventScroll: true }); const position = positions.get(card); diff --git a/scripts/motion-checks.mjs b/scripts/motion-checks.mjs index 0b88e24..61ad40d 100644 --- a/scripts/motion-checks.mjs +++ b/scripts/motion-checks.mjs @@ -109,13 +109,13 @@ export async function canvasMotion(page, load) { const body = await card.locator("time").boundingBox(); await page.mouse.move(body.x + body.width / 2, body.y + body.height / 2); await page.mouse.down(); - await page.mouse.move(box.x + 110, body.y + body.height / 2, { + await page.mouse.move(before.x + before.width / 2 < box.x + box.width / 2 ? box.x + box.width - 110 : box.x + 110, body.y + body.height / 2, { steps: 16, }); await page.waitForTimeout(160); const held = await card.boundingBox(); assert( - held.x < before.x - 100, + Math.abs(held.x - before.x) > 100, "A card moves across the finite world at 65% zoom", ); await page.mouse.up(); @@ -191,55 +191,102 @@ export async function canvasBounds(page, load) { await board.dispatchEvent(type, type === "wheel" ? { deltaY: -100 } : {}); assert(!(await page.locator("#playground-instructions").isVisible()), `${type} dismisses instructions`); } + const snapshot = () => page.locator("[data-playground-card]").evaluateAll(cards => cards.map(card => { + const transform = new DOMMatrix(getComputedStyle(card).transform); + return { id: card.dataset.widgetId, x: card.offsetLeft + transform.m41, + y: card.offsetTop + transform.m42, width: card.offsetWidth, height: card.offsetHeight }; + })); + const worldSize = () => page.locator(".playground-world").evaluate(w => [w.offsetWidth, w.offsetHeight]); for (const width of [1440, 390]) { await page.setViewportSize({ width, height: 1000 }); await load(page, "/play-lab#canvas"); await page.reload({ waitUntil: "networkidle" }); const board = page.locator("[data-playground-board]"); const world = page.locator(".playground-world"); - const edgeCard = page.locator(".playground-note"); - for (const direction of ["ArrowRight", "ArrowLeft"]) { - await edgeCard.focus(); - for (let step = 0; step < 60; step++) await edgeCard.press(`Shift+${direction}`); - const edge = await edgeCard.evaluate((el, direction) => { - const world = el.parentElement; - const camera = new DOMMatrix(getComputedStyle(world).transform); - const card = new DOMMatrix(getComputedStyle(el).transform); - const x = camera.m41 + (el.offsetLeft + card.m41) * camera.a; - return direction === "ArrowLeft" ? x : world.parentElement.clientWidth - x - el.offsetWidth * camera.a; - }, direction); - assert(Math.abs(edge - 18) < 1, `${width}: card reaches the board's ${direction} edge`); + const reset = page.locator("[data-playground-reset]"); + const clock = page.locator(".playground-clock"); + const initial = await snapshot(); + const dimensions = await worldSize(); + const boxes = await page.locator("[data-playground-card]").evaluateAll(cards => cards.map(c => { + const r = c.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; + })); + for (let i = 0; i < boxes.length; i++) for (let j = i + 1; j < boxes.length; j++) { + const a = boxes[i], b = boxes[j]; + const overlap = Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x)) * + Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y)); + assert(overlap / Math.min(a.w * a.h, b.w * b.h) < .03, "Random starting cards have clear spacing"); } - await page.locator("[data-playground-reset]").click(); - for (let step = 0; step < 5; step++) await page.locator('[data-playground-zoom="in"]').click(); - await board.focus(); - for (const key of ["ArrowRight", "ArrowDown", "ArrowLeft", "ArrowUp"]) { - for (let step = 0; step < 25; step++) await board.press(`Shift+${key}`); - const edge = await world.getAttribute("style"); - await board.press(`Shift+${key}`); - assert.equal(await world.getAttribute("style"), edge, `${width}: finite ${key} edge`); - } - if (width === 1440) { - const note = page.locator(".playground-note"); - const heading = await note.locator(".widget-heading").boundingBox(); - const rect = await board.boundingBox(); - const initial = await world.evaluate(el => new DOMMatrix(getComputedStyle(el).transform).m41); - await page.mouse.move(heading.x + heading.width / 2, heading.y + heading.height / 2); + await board.scrollIntoViewIfNeeded(); + let rect = await board.boundingBox(); + const cameraBefore = await world.getAttribute("style"); + await page.mouse.move(rect.x + 5, rect.y + rect.height / 2); + await page.mouse.down(); + await page.mouse.move(rect.x + 90, rect.y + rect.height / 2, { steps: 10 }); + await page.waitForTimeout(250); + await page.mouse.up(); + assert.notEqual(await world.getAttribute("style"), cameraBefore, "Dragging empty space pans the camera"); + assert.deepEqual(await snapshot(), initial, "Panning never changes card world coordinates"); + + for (const zoom of [.65, 1, 1.65]) { + await reset.click(); + const direction = zoom < 1 ? "out" : "in"; + for (let i = 0; i < (zoom < 1 ? 3 : zoom > 1 ? 5 : 0); i++) + await page.locator(`[data-playground-zoom="${direction}"]`).click(); + assert.deepEqual(await worldSize(), dimensions, "Zoom cannot change world dimensions"); + // Position the clock centrally with ordinary keyboard panning before dragging. + await clock.evaluate(el => el.focus({ preventScroll: true })); + for (let i = 0; i < 12; i++) { + const box = await clock.boundingBox(); rect = await board.boundingBox(); + const dx = rect.x + rect.width / 2 - box.x - box.width / 2; + const dy = rect.y + rect.height / 2 - box.y - box.height / 2; + if (Math.abs(dx) > 55) await board.dispatchEvent("keydown", { key: dx > 0 ? "ArrowLeft" : "ArrowRight", shiftKey: true }); + if (Math.abs(dy) > 55) await board.dispatchEvent("keydown", { key: dy > 0 ? "ArrowUp" : "ArrowDown", shiftKey: true }); + } + await board.scrollIntoViewIfNeeded(); + rect = await board.boundingBox(); + const before = await snapshot(); + const scale = await world.evaluate(w => new DOMMatrix(getComputedStyle(w).transform).a); + const box = await clock.boundingBox(); + const camera = await world.getAttribute("style"); + const dx = box.x + box.width / 2 > rect.x + rect.width / 2 ? -48 : 48; + await clock.locator("p").last().evaluate(el => window.getSelection().selectAllChildren(el)); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); await page.mouse.down(); - await page.mouse.move(rect.x + rect.width - 3, heading.y + heading.height / 2, { steps: 12 }); - await page.waitForTimeout(1800); - assert(await world.evaluate(el => new DOMMatrix(getComputedStyle(el).transform).m41) < initial - 100, - "Dragging at a zoomed edge pans across the full world"); + await page.mouse.move(box.x + box.width / 2 + dx, box.y + box.height / 2, { steps: 8 }); + await page.waitForTimeout(300); await page.mouse.up(); - await page.waitForTimeout(1400); - assert(await note.evaluate(el => { - const x = el.offsetLeft + new DOMMatrix(getComputedStyle(el).transform).m41; - const board = el.closest("[data-playground-board]"); - const zoom = parseInt(board.querySelector("[data-playground-zoom-label]").value) / 100; - const fit = new DOMMatrix(getComputedStyle(el.parentElement).transform).a / zoom; - const left = (el.parentElement.offsetWidth - board.clientWidth / fit) / 2; - return x >= left + 17 / fit && x + el.offsetWidth <= left + (board.clientWidth - 17) / fit; - }), "Thrown card remains inside the world"); + const after = await snapshot(); + const from = before.find(c => c.id === "clock"), to = after.find(c => c.id === "clock"); + assert(Math.abs(to.x - from.x - dx / scale) < 2, `${width} at ${zoom}: pointer movement is converted to world units (${JSON.stringify({from, to, dx, scale, box, rect})})`); + assert.equal(await world.getAttribute("style"), camera, "Dragging a card away from edges does not pan the camera"); + assert.deepEqual(after.filter(c => c.id !== "clock"), before.filter(c => c.id !== "clock"), "Only the dragged card moves"); + assert.equal(await page.evaluate(() => getSelection().toString()), "", "Dragging clears accidental text selection"); + assert(await page.locator(".playground-work img").evaluate(img => !img.draggable)); } + // Moving to a world edge must reach the same position before and after zooming out. + await reset.click(); + await clock.focus(); + for (let i = 0; i < 65; i++) await clock.dispatchEvent("keydown", { key: "ArrowRight", shiftKey: true }); + const edgeAt100 = (await snapshot()).find(c => c.id === "clock"); + for (let i = 0; i < 3; i++) await page.locator('[data-playground-zoom="out"]').click(); + for (let i = 0; i < 65; i++) await clock.dispatchEvent("keydown", { key: "ArrowRight", shiftKey: true }); + const edgeAt65 = (await snapshot()).find(c => c.id === "clock"); + assert.equal(edgeAt100.x, edgeAt65.x, "World edge is independent of zoom or camera"); + const visibleClock = await clock.boundingBox(); rect = await board.boundingBox(); + assert(visibleClock.x >= rect.x - 1 && visibleClock.x + visibleClock.width <= rect.x + rect.width + 1, + "Zooming out exposes usable space beyond the initial view"); + assert(edgeAt65.x + edgeAt65.width > dimensions[0] * .9, "Cards can reach the full world edge"); + assert(edgeAt65.x + edgeAt65.width <= dimensions[0], "Cards stay inside the finite world"); + await reset.click(); + assert.deepEqual(await snapshot(), initial, "Reset restores the original random arrangement"); + for (let i = 0; i < 5; i++) await page.locator('[data-playground-zoom="in"]').click(); + for (const key of ["ArrowRight", "ArrowDown", "ArrowLeft", "ArrowUp"]) { + for (let i = 0; i < 65; i++) await board.dispatchEvent("keydown", { key, shiftKey: true }); + const edge = await world.getAttribute("style"); + await board.dispatchEvent("keydown", { key, shiftKey: true }); + assert.equal(await world.getAttribute("style"), edge, `${width}: finite ${key} camera boundary`); + } + await page.reload({ waitUntil: "networkidle" }); + assert.notDeepEqual(await snapshot(), initial, "A fresh visit starts with a new random arrangement"); } -} +} \ No newline at end of file From ccacd797caa5c0683a56beb2792becc66b22d055 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Sun, 13 Sep 2026 11:49:11 +0530 Subject: [PATCH 13/22] feat(play-lab): add calendar calculator and mood widgets --- docs/design-system.md | 2 +- play-lab.html | 3 + playground.css | 26 +++++++ playground.js | 112 ++++++++++++++++++++++++++- scripts/motion-checks.mjs | 26 ++++++- scripts/playground-widget-checks.mjs | 37 +++++++++ scripts/playground.mjs | 3 + scripts/verify.mjs | 11 +-- 8 files changed, 208 insertions(+), 12 deletions(-) create mode 100644 scripts/playground-widget-checks.mjs diff --git a/docs/design-system.md b/docs/design-system.md index b03b067..127bad8 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -26,7 +26,7 @@ The six folder tints are #b8f0d8, #d4c9f5, #b8cef5, #f5d4b8, #f2a65a and #c8e6c0 - Work: a centered heading, shared category/search controls, six featured folders and six archive folders. Curated order is preserved until an explicit date sort. Four private-work cards use original illustrations, sans-serif headlines and the article index’s clean art-and-copy layout. - Blog: a left-aligned engineering publication header, featured article, search/filter/sort controls and a card grid with original vector artwork. Local readers use clear sans-serif titles, an author byline, a sidebar section index, a generous text column and code panels. - Project details: large left-aligned title, rounded metadata tray, genuine project imagery or a labeled placeholder, sticky section navigation and next-project link. -- Play Lab: a rounded canvas with an editable sticky note, focus timer, checklist, personal vote, colour mixer, clock and project widget. Drag any non-control surface or use arrow keys; releases carry momentum and bounce softly at the canvas edges. The canvas uses a near-viewport rounded blue frame, six pastel swatches with a separate background reset, a floating pan/zoom hint, centered zoom/reset controls, and three draggable pins. The seven existing widget cards retain their content and visual design. Desktop and mobile start in Canvas view with the closer card scale. A fresh visit shuffles cards into spaced slots with varied offsets and angles; List view is available for reading. The instructions hide on the first canvas activity, including pointer movement, dragging, scrolling, zooming, typing or keyboard navigation. The palette reset uses a centered SVG icon aligned with the swatches. +- Play Lab: a rounded canvas with an editable sticky note, focus timer, checklist, personal vote, colour mixer, clock and project widget. Drag any non-control surface or use arrow keys; releases carry momentum and bounce softly at the canvas edges. The canvas uses a near-viewport rounded blue frame, six pastel swatches with a separate background reset, a floating pan/zoom hint, centered zoom/reset controls, and three draggable pins. The seven original widgets retain their content and visual design, joined by a browsable calendar, calculator, and daily mood check-in saved locally. Ten cards use spaced slots that expand into the existing finite world as needed. Desktop and mobile start in Canvas view with the closer card scale. A fresh visit shuffles cards into spaced slots with varied offsets and angles; List view is available for reading. The instructions hide on the first canvas activity, including pointer movement, dragging, scrolling, zooming, typing or keyboard navigation. The palette reset uses a centered SVG icon aligned with the swatches. - 404: a centered message, playable Bug Run panel and return-home link. Mobile uses top identity/menu controls, one-column collections, smaller type, two-column metadata with the third item spanning both, and a compact bottom reading index. Native links and article content remain usable without JavaScript. diff --git a/play-lab.html b/play-lab.html index 677758d..25a7324 100644 --- a/play-lab.html +++ b/play-lab.html @@ -48,6 +48,9 @@

You have a vote

What should I
build next?

Just for fun. Your pick stays in this browser.

Make a little colour

Good things
come in colour.
#D1C2F0

+

A day at a time

Pick a day. Make a little plan.

+

Quick numbers

0
+

A small check-in

How's your
day going?

No right answer. Just a moment for you.

Drag to pan Scroll to zoom
diff --git a/playground.css b/playground.css index f5a246f..cc587d7 100644 --- a/playground.css +++ b/playground.css @@ -1502,3 +1502,29 @@ -webkit-user-select: none; } } +.playground-calendar { width: 310px; background: #fffaf1; padding: 23px; } +.calendar-navigation { display: flex; align-items: center; justify-content: space-between; margin: 17px 0; gap: 7px; } +.calendar-navigation h2 { font: 500 18px/1.2 "Instrument Sans", sans-serif; margin: 0; } +.calendar-navigation button { width: 32px; height: 32px; border: 0; border-radius: 50%; background: #efe9db; color: #514b41; font-size: 22px; } +.calendar-weekdays, .calendar-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 3px; text-align: center; } +.calendar-weekdays { font-size: 10px; color: #7b7468; padding-bottom: 9px; } +.calendar-grid button { min-width: 0; height: 32px; border: 0; border-radius: 50%; color: #484438; background: transparent; font-size: 11px; } +.calendar-grid button:hover { background: #f1e8d3; } +.calendar-grid button[aria-current="date"] { box-shadow: inset 0 0 0 1px #ae8753; } +.calendar-grid button[aria-pressed="true"] { background: #705739; color: white; } +.playground-calendar > p { min-height: 29px; font-size: 11px; color: #776a57; line-height: 1.5; margin: 16px 0 3px; } +.playground-calculator { width: 290px; background: #e4eaf0; } +.calculator-display { display: block; margin: 18px 0; padding: 16px 12px; border-radius: 12px; background: #f8faf9; font: 400 25px/1.3 "Instrument Sans", sans-serif; text-align: right; color: #344452; overflow-wrap: anywhere; } +.calculator-keys { display: grid; grid-template-columns: repeat(4, 1fr); gap: 7px; } +.calculator-keys button { height: 43px; border: 0; border-radius: 11px; background: #f6f7f9; color: #455360; font-size: 17px; box-shadow: 0 2px 0 #cbd4dd; } +.calculator-keys button:nth-child(4n) { background: #bfcedd; } +.calculator-keys button:last-child { background: #586f85; color: white; } +.calculator-keys button:active { transform: translateY(2px); box-shadow: none; } +.playground-mood { width: 285px; background: #f4dce4; } +.playground-mood h2 { margin: 22px 0; font-size: 36px; } +.mood-options { display: grid; grid-template-columns: repeat(4, 1fr); gap: 5px; } +.mood-options button { display: grid; gap: 8px; padding: 12px 3px; border: 0; border-radius: 14px; background: #fff7fa; color: #795465; font-size: 10px; } +.mood-options button span { font-size: 24px; } +.mood-options button[aria-pressed="true"] { background: #855d74; color: #fff; } +.playground-mood > p { margin: 18px 0 5px; min-height: 32px; color: #795465; font-size: 11px; line-height: 1.6; } +@media (prefers-reduced-motion: reduce) { .calculator-keys button:active { transform: none; } } diff --git a/playground.js b/playground.js index 23cce23..14596ae 100644 --- a/playground.js +++ b/playground.js @@ -171,9 +171,23 @@ }; } function scatterCards() { - const layoutWidth = canvasArea.width * 0.65, layoutHeight = canvasArea.height * 0.65; + let layoutWidth = canvasArea.width * 0.65, layoutHeight = canvasArea.height * 0.65; const widest = Math.max(...cards.map(card => card.offsetWidth)) + 48; - const columns = Math.max(2, Math.min(canvasArea.compact ? 2 : 4, Math.floor(layoutWidth / widest))); + const tallest = Math.max(...cards.map(card => card.offsetHeight)) + 48; + const candidates = []; + for (let columns = 2; columns <= Math.floor(canvasArea.width / widest); columns++) { + const rows = Math.ceil(cards.length / columns); + const width = Math.max(layoutWidth, columns * widest); + const height = Math.max(layoutHeight, rows * tallest); + if (height <= canvasArea.height) candidates.push({ columns, width, height }); + } + // Use more of the existing finite world as the collection grows, keeping + // widget text at the same readable scale and reserving room for rotation. + candidates.sort((a, b) => a.width * a.height - b.width * b.height); + const layout = candidates[0]; + const columns = layout?.columns || Math.max(2, Math.floor(canvasArea.width / widest)); + layoutWidth = layout?.width || canvasArea.width; + layoutHeight = layout?.height || canvasArea.height; const rows = Math.ceil(cards.length / columns); const slots = Array.from({ length: rows * columns }, (_, index) => index); for (let i = slots.length - 1; i > 0; i--) { @@ -502,6 +516,100 @@ }); }); taskProgress(); + const today = new Date(); + let calendarMonth = new Date(today.getFullYear(), today.getMonth(), 1); + let selectedDay = ""; + function renderCalendar() { + const year = calendarMonth.getFullYear(), month = calendarMonth.getMonth(); + board.querySelector("[data-calendar-month]").textContent = + calendarMonth.toLocaleDateString(undefined, { month: "long", year: "numeric" }); + const grid = board.querySelector("[data-calendar-grid]"); + grid.replaceChildren(); + const offset = (calendarMonth.getDay() + 6) % 7; + for (let i = 0; i < offset; i++) { + const spacer = document.createElement("span"); + spacer.setAttribute("aria-hidden", "true"); + grid.append(spacer); + } + for (let day = 1; day <= new Date(year, month + 1, 0).getDate(); day++) { + const date = new Date(year, month, day); + const key = `${year}-${month}-${day}`; + const button = document.createElement("button"); + button.type = "button"; + button.textContent = day; + button.setAttribute("aria-label", date.toLocaleDateString(undefined, { dateStyle: "full" })); + button.setAttribute("aria-pressed", String(selectedDay === key)); + if (date.toDateString() === today.toDateString()) button.setAttribute("aria-current", "date"); + button.addEventListener("click", () => { + selectedDay = key; + grid.querySelectorAll("button").forEach(item => item.setAttribute("aria-pressed", String(item === button))); + board.querySelector("[data-calendar-selection]").textContent = date.toLocaleDateString(undefined, { dateStyle: "long" }); + }); + grid.append(button); + } + } + board.querySelectorAll("[data-calendar-step]").forEach(button => button.addEventListener("click", () => { + calendarMonth.setMonth(calendarMonth.getMonth() + Number(button.dataset.calendarStep)); + renderCalendar(); + })); + board.querySelector("[data-calendar-today]").addEventListener("click", () => { + calendarMonth = new Date(today.getFullYear(), today.getMonth(), 1); + selectedDay = `${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`; + renderCalendar(); + board.querySelector("[data-calendar-selection]").textContent = "Back to today."; + }); + renderCalendar(); + + let calculatorValue = "0", calculatorMemory = null, calculatorOperator = null, newNumber = true; + const calculatorDisplay = board.querySelector("[data-calculator-display]"); + function calculate(key) { + const number = Number(calculatorValue); + const format = value => Number.isFinite(value) ? String(Number(value.toPrecision(12))) : "Error"; + const resolve = () => { + if (calculatorMemory === null || !calculatorOperator) return number; + return { "+": () => calculatorMemory + number, "−": () => calculatorMemory - number, + "×": () => calculatorMemory * number, "÷": () => number === 0 ? NaN : calculatorMemory / number }[calculatorOperator](); + }; + if (key === "C") { + calculatorValue = "0"; calculatorMemory = calculatorOperator = null; newNumber = true; + } else if (/^[0-9.]$/.test(key)) { + if (calculatorValue === "Error") calculatorMemory = calculatorOperator = null; + if (newNumber || calculatorValue === "Error") calculatorValue = "0"; + newNumber = false; + if (key === ".") { if (!calculatorValue.includes(".")) calculatorValue += "."; } + else if (calculatorValue.length < 13) calculatorValue = calculatorValue === "0" ? key : calculatorValue + key; + } else if (calculatorValue !== "Error") { + if (key === "±") calculatorValue = format(-number); + else if (key === "%") calculatorValue = format(number / 100); + else if (key === "⌫") { calculatorValue = calculatorValue.slice(0, -1); if (!calculatorValue || calculatorValue === "-") calculatorValue = "0"; } + else if (["+", "−", "×", "÷"].includes(key)) { + if (!newNumber) calculatorValue = format(resolve()); + calculatorMemory = Number(calculatorValue); calculatorOperator = key; newNumber = true; + } else if (key === "=") { + calculatorValue = format(resolve()); calculatorMemory = calculatorOperator = null; newNumber = true; + } + } + calculatorDisplay.value = calculatorValue; + } + board.querySelectorAll("[data-calculator-key]").forEach(button => button.addEventListener("click", () => calculate(button.dataset.calculatorKey))); + board.querySelector(".playground-calculator").addEventListener("keydown", event => { + if (event.target !== event.currentTarget) return; + const key = ({ "*": "×", "/": "÷", "-": "−", Enter: "=", Backspace: "⌫", Escape: "C" })[event.key] || event.key; + if (/^[0-9.+%=]$/.test(key) || ["×", "÷", "−", "⌫", "C"].includes(key)) { event.preventDefault(); calculate(key); } + }); + + const moodDate = `${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`; + function showMood(value) { + board.querySelectorAll("[data-mood]").forEach(button => button.setAttribute("aria-pressed", String(button.dataset.mood === value))); + board.querySelector("[data-mood-status]").textContent = value ? `${value[0].toUpperCase() + value.slice(1)} today. A little check-in, just for you.` : "No right answer. Just a moment for you."; + } + showMood(saved.moodDate === moodDate && ["quiet", "okay", "good", "great"].includes(saved.mood) ? saved.mood : null); + board.querySelectorAll("[data-mood]").forEach(button => button.addEventListener("click", () => { + saved.mood = button.dataset.mood; saved.moodDate = moodDate; save(); showMood(saved.mood); + })); + board.querySelector("[data-mood-clear]").addEventListener("click", () => { + delete saved.mood; delete saved.moodDate; save(); showMood(null); + }); let pinDrag = null; let pinGhost = null; const pinTools = [...board.querySelectorAll("[data-pin-colour]")]; diff --git a/scripts/motion-checks.mjs b/scripts/motion-checks.mjs index 61ad40d..3632d13 100644 --- a/scripts/motion-checks.mjs +++ b/scripts/motion-checks.mjs @@ -67,11 +67,19 @@ export async function canvasMotion(page, load) { await card.focus(); await card.press("Enter"); assert.equal(await card.getAttribute("data-pinned"), null, "Click and Enter cannot place a pin"); + for (let i = 0; i < 35; i++) { + const box = await card.boundingBox(), rect = await board.boundingBox(); + const dx = rect.x + rect.width / 2 - box.x - box.width / 2; + const dy = rect.y + rect.height / 2 - box.y - box.height / 2; + if (Math.abs(dx) > 55) await board.dispatchEvent("keydown", { key: dx > 0 ? "ArrowLeft" : "ArrowRight", shiftKey: true }); + if (Math.abs(dy) > 55) await board.dispatchEvent("keydown", { key: dy > 0 ? "ArrowUp" : "ArrowDown", shiftKey: true }); + } + await board.scrollIntoViewIfNeeded(); const pinTool = await page.locator('[data-pin-colour="gold"]').boundingBox(); const pinTarget = await card.boundingBox(); await page.mouse.move(pinTool.x + pinTool.width / 2, pinTool.y + pinTool.height / 2); await page.mouse.down(); - await page.mouse.move(pinTarget.x + pinTarget.width / 2, pinTarget.y + 30, { steps: 12 }); + await page.mouse.move(pinTarget.x + pinTarget.width / 2, pinTarget.y + pinTarget.height / 2, { steps: 12 }); await page.mouse.up(); assert.equal(await card.getAttribute("data-pinned"), "gold"); const pinnedX = (await card.boundingBox()).x; @@ -219,9 +227,19 @@ export async function canvasBounds(page, load) { await board.scrollIntoViewIfNeeded(); let rect = await board.boundingBox(); const cameraBefore = await world.getAttribute("style"); - await page.mouse.move(rect.x + 5, rect.y + rect.height / 2); + const empty = await board.evaluate(el => { + const rect = el.getBoundingClientRect(), world = el.querySelector(".playground-world"); + for (let y = 120; y < rect.height - 110; y += 30) { + for (let x = 15; x < rect.width - 110; x += 30) { + const target = document.elementFromPoint(rect.x + x, rect.y + y); + if (target === el || target === world) return { x: rect.x + x, y: rect.y + y }; + } + } + }); + assert(empty, "Random layout retains empty space for panning"); + await page.mouse.move(empty.x, empty.y); await page.mouse.down(); - await page.mouse.move(rect.x + 90, rect.y + rect.height / 2, { steps: 10 }); + await page.mouse.move(empty.x + 85, empty.y, { steps: 10 }); await page.waitForTimeout(250); await page.mouse.up(); assert.notEqual(await world.getAttribute("style"), cameraBefore, "Dragging empty space pans the camera"); @@ -289,4 +307,4 @@ export async function canvasBounds(page, load) { await page.reload({ waitUntil: "networkidle" }); assert.notDeepEqual(await snapshot(), initial, "A fresh visit starts with a new random arrangement"); } -} \ No newline at end of file +} diff --git a/scripts/playground-widget-checks.mjs b/scripts/playground-widget-checks.mjs new file mode 100644 index 0000000..be387ca --- /dev/null +++ b/scripts/playground-widget-checks.mjs @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; + +export async function playgroundWidgetChecks(page, load) { + await page.setViewportSize({ width: 1440, height: 1000 }); + await load(page, "/play-lab#canvas"); + assert.equal(await page.locator("[data-playground-card]").count(), 10); + await page.locator("[data-playground-view]").click(); + const month = page.locator("[data-calendar-month]"); + const currentMonth = await month.innerText(); + const expectedDays = await page.evaluate(() => { + const now = new Date(); + return new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate(); + }); + assert.equal(await page.locator("[data-calendar-grid] button").count(), expectedDays); + await page.locator('[data-calendar-step="1"]').click(); + assert.notEqual(await month.innerText(), currentMonth); + await page.locator('[data-calendar-step="-1"]').click(); + assert.equal(await month.innerText(), currentMonth); + await page.locator("[data-calendar-grid] button").first().click(); + assert.equal(await page.locator('[data-calendar-grid] button[aria-pressed="true"]').count(), 1); + await page.locator("[data-calendar-today]").click(); + assert.equal(await page.locator('[data-calendar-grid] button[aria-current="date"]').getAttribute("aria-pressed"), "true"); + const calculator = page.locator(".playground-calculator"); + await calculator.focus(); + for (const key of ["1", "2", ".", "5", "+", "7", ".", "5", "Enter"]) await calculator.press(key); + assert.equal(await page.locator("[data-calculator-display]").innerText(), "20"); + for (const key of ["C", "9", "÷", "0", "="]) await page.locator(`[data-calculator-key="${key}"]`).click(); + assert.equal(await page.locator("[data-calculator-display]").innerText(), "Error"); + await page.locator('[data-calculator-key="3"]').click(); + assert.equal(await page.locator("[data-calculator-display]").innerText(), "3"); + await page.locator('[data-mood="good"]').click(); + await page.reload({ waitUntil: "networkidle" }); + await page.locator("[data-playground-view]").click(); + assert.equal(await page.locator('[data-mood="good"]').getAttribute("aria-pressed"), "true"); + await page.locator("[data-mood-clear]").click(); + assert.equal(await page.locator('[data-mood][aria-pressed="true"]').count(), 0); +} diff --git a/scripts/playground.mjs b/scripts/playground.mjs index c5da6ec..206198b 100644 --- a/scripts/playground.mjs +++ b/scripts/playground.mjs @@ -25,6 +25,9 @@ export function renderPlayground({ profile = {} } = {}) {
${widgetTitle("✳", "You have a vote")}

What should I
build next?

Just for fun. Your pick stays in this browser.

${widgetTitle("◒", "Make a little colour")}
Good things
come in colour.
#D1C2F0

${widgetTitle("↗", "From idea to browser")}Markdown Viewer editor and rendered preview

Markdown Viewer

A calmer place to write, preview, and share.

Explore the project
+
${widgetTitle("▦", "A day at a time")}

Pick a day. Make a little plan.

+
${widgetTitle("+", "Quick numbers")}0
${["C","±","%","÷","7","8","9","×","4","5","6","−","1","2","3","+","0",".","⌫","="].map(key=>``).join("")}
+
${widgetTitle("☀", "A small check-in")}

How's your
day going?

${[["quiet","☁","Quiet"],["okay","◒","Okay"],["good","☀","Good"],["great","✦","Great"]].map(([value,icon,label])=>``).join("")}

No right answer. Just a moment for you.

Drag to pan Scroll to zoom
diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 72d5ab9..68c30d7 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -9,6 +9,7 @@ import { projectCollections } from "./project-selection.mjs"; import { refinementChecks } from "./refinement-checks.mjs"; import { conversationTiming, canvasMotion, canvasBounds } from "./motion-checks.mjs"; import { dotChecks } from "./dot-checks.mjs"; +import { playgroundWidgetChecks } from "./playground-widget-checks.mjs"; const args = process.argv.slice(2); const option = (name, fallback) => @@ -1099,7 +1100,7 @@ async function playground(page) { assert(await page.locator("#canvas").isVisible()); assert(!(await page.locator("#interactions").isVisible())); const card = page.locator("[data-playground-card]").first(); - assert.equal(await page.locator("[data-playground-card]").count(), 7); + assert.equal(await page.locator("[data-playground-card]").count(), 10); await card.focus(); const before = await card.boundingBox(); await card.press("ArrowRight"); @@ -1135,7 +1136,7 @@ async function playground(page) { await page.locator("[data-playground-view]").click(); assert.equal( await page.locator("[data-playground-card]:visible").count(), - 7, + 10, "All cards remain readable on mobile", ); await page.locator('a[href="#interactions"]').click(); @@ -1456,7 +1457,7 @@ try { ], [ "zoomed canvas, tab isolation and water feedback", - async () => { await canvasMotion(page, load); await canvasBounds(page, load); }, + async () => { await canvasMotion(page, load); await canvasBounds(page, load); await playgroundWidgetChecks(page, load); }, ], ["game start, pause, reset and keyboard", () => game(page)], ["clipboard success and denial", () => clipboard(browser)], @@ -1624,8 +1625,8 @@ try { if (route === "/play-lab") { assert.equal( await fallback.locator("[data-playground-card]").count(), - 7, - "Play Lab keeps the seven canvas widgets readable without JavaScript", + 10, + "Play Lab keeps the ten canvas widgets readable without JavaScript", ); assert.equal( await fallback.locator(".playground-experiment").count(), From 81c02774e40e6b8cb88e4e9672a2df42ebf56dfa Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Sun, 13 Sep 2026 12:04:13 +0530 Subject: [PATCH 14/22] fix(play-lab): round calendar controls and theme dark surfaces --- docs/design-system.md | 4 +- playground.css | 119 +++++++++++++++++++++++++++- playground.js | 18 ++++- scripts/playground-theme-checks.mjs | 76 ++++++++++++++++++ scripts/verify.mjs | 3 +- 5 files changed, 211 insertions(+), 9 deletions(-) create mode 100644 scripts/playground-theme-checks.mjs diff --git a/docs/design-system.md b/docs/design-system.md index 127bad8..820d25a 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -26,7 +26,7 @@ The six folder tints are #b8f0d8, #d4c9f5, #b8cef5, #f5d4b8, #f2a65a and #c8e6c0 - Work: a centered heading, shared category/search controls, six featured folders and six archive folders. Curated order is preserved until an explicit date sort. Four private-work cards use original illustrations, sans-serif headlines and the article index’s clean art-and-copy layout. - Blog: a left-aligned engineering publication header, featured article, search/filter/sort controls and a card grid with original vector artwork. Local readers use clear sans-serif titles, an author byline, a sidebar section index, a generous text column and code panels. - Project details: large left-aligned title, rounded metadata tray, genuine project imagery or a labeled placeholder, sticky section navigation and next-project link. -- Play Lab: a rounded canvas with an editable sticky note, focus timer, checklist, personal vote, colour mixer, clock and project widget. Drag any non-control surface or use arrow keys; releases carry momentum and bounce softly at the canvas edges. The canvas uses a near-viewport rounded blue frame, six pastel swatches with a separate background reset, a floating pan/zoom hint, centered zoom/reset controls, and three draggable pins. The seven original widgets retain their content and visual design, joined by a browsable calendar, calculator, and daily mood check-in saved locally. Ten cards use spaced slots that expand into the existing finite world as needed. Desktop and mobile start in Canvas view with the closer card scale. A fresh visit shuffles cards into spaced slots with varied offsets and angles; List view is available for reading. The instructions hide on the first canvas activity, including pointer movement, dragging, scrolling, zooming, typing or keyboard navigation. The palette reset uses a centered SVG icon aligned with the swatches. +- Play Lab: a rounded canvas with an editable sticky note, focus timer, checklist, personal vote, colour mixer, clock and project widget. Drag any non-control surface or use arrow keys; releases carry momentum and bounce softly at the canvas edges. The canvas uses a near-viewport rounded blue frame, six pastel swatches with a separate background reset, a floating pan/zoom hint, centered zoom/reset controls, and three draggable pins. The seven original widgets retain their content and visual design, joined by a browsable calendar, calculator, and daily mood check-in saved locally. Ten cards use spaced slots that expand into the existing finite world as needed. Desktop and mobile start in Canvas view with the closer card scale. A fresh visit shuffles cards into spaced slots with varied offsets and angles; List view is available for reading. The instructions hide on the first canvas activity, including pointer movement, dragging, scrolling, zooming, typing or keyboard navigation. The palette reset uses a centered SVG icon aligned with the swatches. Calendar navigation and date buttons keep equal width and height for true circles. Dark mode provides tinted dark backgrounds for all ten widgets, their controls, and all six canvas palettes, with readable text and selected states. - 404: a centered message, playable Bug Run panel and return-home link. Mobile uses top identity/menu controls, one-column collections, smaller type, two-column metadata with the third item spanning both, and a compact bottom reading index. Native links and article content remain usable without JavaScript. @@ -35,7 +35,7 @@ Mobile uses top identity/menu controls, one-column collections, smaller type, tw Folder transforms and hover lifts use ease-out and spring-like curves. Pointer movement locally enlarges dots within a soft radius. Word-sized masks protect visible text with one pixel of padding; rounded masks protect painted UI surfaces. Transparent links and layout wrappers do not clear their surrounding area, and hovering controls keeps nearby dots reactive. Normal dots retain uniform opacity through all four edges and corners; only the pointer highlight blends back into the grid over a 225px radius. Pointer falloff blends dot size and opacity; dark mode uses a quieter base and highlight. Hover dot radii peak at 3px in light mode and 2.35px in dark mode; highlight colors and opacity remain unchanged. Touch and reduced-motion devices retain only static dots; rendering stops when idle. Without JavaScript, the page uses the plain canvas color. Soft shadows replace decorative borders. Mouse focus avoids oversized rings while keyboard focus remains visible. The game starts only on input. Reduced-motion styles suppress decorative movement. -The Interactions section of Play Lab contains six working demos: shape switching, selectable spring motion, a tilting/turning studio pass, click celebration, an adjustable spinning petal bloom and a tactile ripple pond with three palettes. A keyboard-accessible segmented control shows exactly one panel at a time, with a short entrance transition. The canvas camera pans independently of cards within finite world boundaries; wheel scrolling zooms around the pointer from 65% to 165%, while scrolling outside the canvas or in List view scrolls the page. Zooming out reveals the same finite world. The finite world is established for the responsive layout to include all space revealed at minimum zoom. Card positions, drag offsets, and limits use that world coordinate system independently of camera translation and current zoom. Boundaries include the rotated/lifted card footprint. Panning changes only the camera; card dragging changes only the selected card, with edge panning to reach distant space. Text/image selection is suppressed while dragging. Dragging near a viewport edge pans the camera to reach the rest of the world, stopping at its boundary. Dragging and throwing cards preserves momentum, while mobile offers a readable List view alongside its initial Canvas view. Pins can only be placed by dragging and dropping onto a card; selecting the card pin unpins it. View reset restores the initial random positions, zoom, and pins, while background reset only resets the canvas colour. The physics demo integrates a damped spring with drag velocity and three response presets. Celebration has a clear burst and halo. The water demo draws soft, low-opacity pastel wave bands without refraction, caustics or specular reflections, then settles; reduced motion produces a still wave pattern. The Depth card tilts over 600ms and flips over 900ms. Physics keeps its draggable spring motion without a visible coil or anchor ring. Keyboard, touch and reduced-motion feedback remain available. +The Interactions section of Play Lab contains six working demos: shape switching, selectable spring motion, a tilting/turning studio pass, click celebration, an adjustable spinning petal bloom and a tactile ripple pond with three palettes. A keyboard-accessible segmented control shows exactly one panel at a time, with a short entrance transition. The canvas camera pans independently of cards within finite world boundaries; wheel scrolling zooms around the pointer from 65% to 165%, while scrolling outside the canvas or in List view scrolls the page. Zooming out reveals the same finite world. The finite world is established for the responsive layout to include all space revealed at minimum zoom. Card positions, drag offsets, and limits use that world coordinate system independently of camera translation and current zoom. Boundaries include the rotated/lifted card footprint. Panning changes only the camera; card dragging changes only the selected card, with edge panning to reach distant space. Text/image selection is suppressed while dragging. Dragging near a viewport edge pans the camera to reach the rest of the world, stopping at its boundary. Dragging and throwing cards preserves momentum, while mobile offers a readable List view alongside its initial Canvas view. Pins can only be placed by dragging and dropping onto a card; selecting the card pin unpins it. View reset restores the initial random positions, zoom, and pins, while background reset only resets the canvas colour. The physics demo integrates a damped spring with drag velocity and three response presets. Celebration has a clear burst and halo. The water demo draws soft, low-opacity pastel wave bands without refraction, caustics or specular reflections, then settles; reduced motion produces a still wave pattern. All six experiment panels, segmented controls, buttons, and pond palettes have dark surfaces. Ripple colours update immediately when the site theme changes, including still reduced-motion ripples. The Depth card tilts over 600ms and flips over 900ms. Physics keeps its draggable spring motion without a visible coil or anchor ring. Keyboard, touch and reduced-motion feedback remain available. ## Self-hosted font assets and licenses diff --git a/playground.css b/playground.css index cc587d7..2591d7b 100644 --- a/playground.css +++ b/playground.css @@ -1505,10 +1505,10 @@ .playground-calendar { width: 310px; background: #fffaf1; padding: 23px; } .calendar-navigation { display: flex; align-items: center; justify-content: space-between; margin: 17px 0; gap: 7px; } .calendar-navigation h2 { font: 500 18px/1.2 "Instrument Sans", sans-serif; margin: 0; } -.calendar-navigation button { width: 32px; height: 32px; border: 0; border-radius: 50%; background: #efe9db; color: #514b41; font-size: 22px; } +.calendar-navigation button { flex: 0 0 32px; width: 32px; height: 32px; padding: 0; display: grid; place-items: center; line-height: 1; border: 0; border-radius: 50%; background: #efe9db; color: #514b41; font-size: 22px; } .calendar-weekdays, .calendar-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 3px; text-align: center; } .calendar-weekdays { font-size: 10px; color: #7b7468; padding-bottom: 9px; } -.calendar-grid button { min-width: 0; height: 32px; border: 0; border-radius: 50%; color: #484438; background: transparent; font-size: 11px; } +.calendar-grid button { width: min(32px, 100%); min-width: 0; height: auto; min-height: 0; aspect-ratio: 1; place-self: center; display: grid; place-items: center; padding: 0; line-height: 1; border: 0; border-radius: 50%; color: #484438; background: transparent; font-size: 11px; } .calendar-grid button:hover { background: #f1e8d3; } .calendar-grid button[aria-current="date"] { box-shadow: inset 0 0 0 1px #ae8753; } .calendar-grid button[aria-pressed="true"] { background: #705739; color: white; } @@ -1528,3 +1528,118 @@ .mood-options button[aria-pressed="true"] { background: #855d74; color: #fff; } .playground-mood > p { margin: 18px 0 5px; min-height: 32px; color: #795465; font-size: 11px; line-height: 1.6; } @media (prefers-reduced-motion: reduce) { .calculator-keys button:active { transform: none; } } + +/* Theme-specific surfaces retain each experiment's colour family. */ +@media screen { + :root[data-theme="dark"] .play-lab-switch a[aria-current="location"] { background: #e5e1ee; color: #25232c; } + :root[data-theme="dark"] .playground-board { + --canvas-color: #202c36; + color: #ecebf0; + border-color: #ffffff0e; + background-image: radial-gradient(#9cb7cd3d .9px, transparent 1.3px); + } + :root[data-theme="dark"] .playground-board[data-canvas-color="pink"] { --canvas-color: #342632; } + :root[data-theme="dark"] .playground-board[data-canvas-color="peach"] { --canvas-color: #362b24; } + :root[data-theme="dark"] .playground-board[data-canvas-color="cream"] { --canvas-color: #323023; } + :root[data-theme="dark"] .playground-board[data-canvas-color="green"] { --canvas-color: #25332c; } + :root[data-theme="dark"] .playground-board[data-canvas-color="lilac"] { --canvas-color: #2d283a; } + :root[data-theme="dark"] .playground-board :is(.playground-view, .playground-pin-tray, .playground-palette, .playground-controls, .playground-canvas-hint) { + background: #252932ed; color: #e0e3e9; border-color: #ffffff14; box-shadow: 0 4px 16px #0003; + } + :root[data-theme="dark"] :is(.playground-controls button, .playground-controls output, .playground-background-reset) { color: #d0d8e2; } + :root[data-theme="dark"] .playground-controls button + button { border-color: #ffffff12; } + :root[data-theme="dark"] .playground-controls button:hover { background: #ffffff0d; } + :root[data-theme="dark"] .playground-swatch-pink { background: #a67391; } + :root[data-theme="dark"] .playground-swatch-peach { background: #ac825e; } + :root[data-theme="dark"] .playground-swatch-cream { background: #a8a06b; } + :root[data-theme="dark"] .playground-swatch-green { background: #709578; } + :root[data-theme="dark"] .playground-swatch-blue { background: #7197b5; } + :root[data-theme="dark"] .playground-swatch-lilac { background: #9682b3; } + :root[data-theme="dark"] .playground-swatch[aria-pressed="true"] { box-shadow: 0 0 0 2px #252932, 0 0 0 3px #bdc8d5; } + + :root[data-theme="dark"] .playground-card { background: #292c34; color: #efece7; box-shadow: 0 12px 26px #0003, 0 3px 6px #0002; } + :root[data-theme="dark"] .playground-card.is-dragging { box-shadow: 0 23px 38px #0006; } + :root[data-theme="dark"] .playground-note { background: #3b3427; } + :root[data-theme="dark"] .playground-note textarea { color: #f0dfac; } + :root[data-theme="dark"] .widget-note-bottom { color: #cabc98; } + :root[data-theme="dark"] .playground-focus { background: #242530; } + :root[data-theme="dark"] .widget-timer-ring { background: radial-gradient(circle at center, #242530 65%, transparent 66%), conic-gradient(#c5b8fd var(--timer-progress, 100%), #454654 0); } + :root[data-theme="dark"] .playground-clock { background: #282d2d; } + :root[data-theme="dark"] .playground-clock-face { background: repeating-conic-gradient(from -1deg, #a7b2ae 0deg 2deg, transparent 2deg 30deg), #363e3b; } + :root[data-theme="dark"] .playground-clock-face::before { background: #363e3b; } + :root[data-theme="dark"] .playground-clock-face i { background: #e0e8e3; } + :root[data-theme="dark"] .playground-clock small { color: #bac3c0; } + :root[data-theme="dark"] .playground-todo { background: #2b382e; } + :root[data-theme="dark"] .playground-todo h2 { color: #dfecd7; } + :root[data-theme="dark"] .playground-todo input { accent-color: #acc796; } + :root[data-theme="dark"] .widget-task-progress { background: #46573f; } + :root[data-theme="dark"] .widget-task-progress span { background: #b3cd9d; } + :root[data-theme="dark"] .playground-todo .widget-completion { color: #bfceaf; } + :root[data-theme="dark"] .playground-vote { background: #332d41; } + :root[data-theme="dark"] .widget-vote-options button { background: #44394f; color: #e8e0f3; } + :root[data-theme="dark"] .widget-vote-options button[aria-pressed="true"] { background: #ab93cc; color: #211b2b; } + :root[data-theme="dark"] .playground-vote > p { color: #c8b7d6; } + :root[data-theme="dark"] .playground-mixer input { accent-color: #b9a1df; } + :root[data-theme="dark"] .widget-copy { background: #42404c; color: #e0d8ea; } + :root[data-theme="dark"] .playground-work { background: #282e36; } + :root[data-theme="dark"] .playground-work img { background: #353c48; } + :root[data-theme="dark"] .playground-work > p { color: #bfc5d0; } + :root[data-theme="dark"] .playground-work a { color: #c3b3f0; } + :root[data-theme="dark"] .playground-calendar { background: #332e26; } + :root[data-theme="dark"] .calendar-navigation button { background: #4b4234; color: #eadac0; } + :root[data-theme="dark"] .calendar-weekdays { color: #c4b69f; } + :root[data-theme="dark"] .calendar-grid button { color: #e7dece; } + :root[data-theme="dark"] .calendar-grid button:hover { background: #514432; } + :root[data-theme="dark"] .calendar-grid button[aria-current="date"] { box-shadow: inset 0 0 0 1px #c5a977; } + :root[data-theme="dark"] .calendar-grid button[aria-pressed="true"] { background: #c5a977; color: #211d16; } + :root[data-theme="dark"] .playground-calendar > p { color: #c9bca6; } + :root[data-theme="dark"] .playground-calculator { background: #29343f; } + :root[data-theme="dark"] .calculator-display { background: #1d2832; color: #e0eaf1; } + :root[data-theme="dark"] .calculator-keys button { background: #3a4958; color: #e4ecf3; box-shadow: 0 2px 0 #17232e; } + :root[data-theme="dark"] .calculator-keys button:nth-child(4n) { background: #526a7d; } + :root[data-theme="dark"] .calculator-keys button:last-child { background: #b5cbdc; color: #192c3b; } + :root[data-theme="dark"] .calculator-keys button:active { box-shadow: none; } + :root[data-theme="dark"] .playground-mood { background: #3b2c36; } + :root[data-theme="dark"] .mood-options button { background: #503b49; color: #edd8e4; } + :root[data-theme="dark"] .mood-options button[aria-pressed="true"] { background: #c397b1; color: #251a22; } + :root[data-theme="dark"] .playground-mood > p { color: #d6b8c9; } + + :root[data-theme="dark"] .playground-experiment-stage { color: #ece8ee; } + :root[data-theme="dark"] .interaction-shape-stage { background: #30283a; } + :root[data-theme="dark"] .interaction-spring-stage { background: #28362c; } + :root[data-theme="dark"] .interaction-depth-stage { background: #26313f; } + :root[data-theme="dark"] .interaction-joy-stage { background: #362b28; } + :root[data-theme="dark"] .interaction-joy-stage.has-celebrated { background: #413028; } + :root[data-theme="dark"] .interaction-bloom-stage { background: #353025; } + :root[data-theme="dark"] .interaction-choice { background: #171b22a8; } + :root[data-theme="dark"] .interaction-choice button { color: #c9c4d2; } + :root[data-theme="dark"] .interaction-choice button[aria-pressed="true"] { background: #50495e; color: #f4edf9; box-shadow: 0 2px 8px #0003; } + :root[data-theme="dark"] .interaction-primary { background: #424942; color: #e2eadd; box-shadow: 0 4px 12px #0003; } + :root[data-theme="dark"] .interaction-shape { background: #735e94; box-shadow: inset 0 3px 6px #ffffff20, inset 0 -7px 18px #0004; } + :root[data-theme="dark"] .interaction-shape[data-interaction-shape="circle"] { background: #49745e; } + :root[data-theme="dark"] .interaction-shape[data-interaction-shape="square"] { background: #99694e; } + :root[data-theme="dark"] .interaction-shape[data-interaction-shape="star"] { background: #4c5e8a; } + :root[data-theme="dark"] .interaction-spring-track::before { background: #4a5c4a; } + :root[data-theme="dark"] .interaction-spring-track i { background: #7d9477; } + :root[data-theme="dark"] .interaction-spring-ball { background: radial-gradient(circle at 33% 27%, #97ba7e, #547d40 65%, #325527); box-shadow: 0 18px 20px #0003, inset 0 1px 3px #ffffff40; } + :root[data-theme="dark"] .interaction-spring-ball:focus-visible { outline-color: #bad6a8; } + :root[data-theme="dark"] .interaction-depth-card { background: linear-gradient(145deg, #514c80, #312c4e); box-shadow: 0 23px 36px #0005, inset 0 1px 2px #ffffff30; } + :root[data-theme="dark"] .interaction-depth-card[aria-pressed="true"] { background: linear-gradient(145deg, #45394f, #2b2433); } + :root[data-theme="dark"] .interaction-depth-stage > p { color: #b9c8dc; } + :root[data-theme="dark"] .interaction-joy-button { background: linear-gradient(#4b3b31, #3b2e28); color: #f0d7c5; box-shadow: 0 16px 25px #0004, inset 0 1px 1px #ffffff20; } + :root[data-theme="dark"] .interaction-joy-button > span { color: #e4ab79; } + :root[data-theme="dark"] .interaction-joy-stage > p { color: #d8bba8; } + :root[data-theme="dark"] .interaction-joy-orbit > span { color: #e1a17a; } + :root[data-theme="dark"] .interaction-joy-orbit > span:nth-child(3n + 1) { color: #bb98d3; } + :root[data-theme="dark"] .interaction-joy-orbit > span:nth-child(3n + 2) { color: #e0b257; } + :root[data-theme="dark"] .interaction-joy-orbit > span:nth-child(4n) { color: #9bc2a3; } + :root[data-theme="dark"] .interaction-bloom i { background: linear-gradient(160deg, #c5a167, #94713f 63%, #6b4e2c); box-shadow: inset 0 1px 2px #fff3, 0 6px 12px #0003; } + :root[data-theme="dark"] .interaction-bloom-control label { color: #dfc9a5; } + :root[data-theme="dark"] .interaction-bloom-control input { accent-color: #c5a167; } + :root[data-theme="dark"] .interaction-bloom-stage .interaction-primary { background: #4a4031; color: #ecdbbe; } + :root[data-theme="dark"] .interaction-ripple-stage { background: #243039; --pond-light: #345460; --pond-mid: #263e4a; --pond-deep: #1b2d36; --pond-ink: #c9e4ed; } + :root[data-theme="dark"] .interaction-ripple-stage[data-ripple-theme="mint"] { background: #28362d; --pond-light: #3b5742; --pond-mid: #2b4133; --pond-deep: #1d3024; --pond-ink: #d2e7cb; } + :root[data-theme="dark"] .interaction-ripple-stage[data-ripple-theme="rose"] { background: #382b35; --pond-light: #5a3e50; --pond-mid: #432e3d; --pond-deep: #2f202d; --pond-ink: #efd3e3; } + :root[data-theme="dark"] .interaction-ripple-pond { box-shadow: inset 0 4px 9px #ffffff12, inset 0 -8px 15px #0002, 0 19px 25px #0003; } + :root[data-theme="dark"] .ripple-decoration i { background: #d3e8ed30; } +} diff --git a/playground.js b/playground.js index 14596ae..66077e9 100644 --- a/playground.js +++ b/playground.js @@ -1428,13 +1428,19 @@ previousHeights, waterPixels, waterTint; + let waterDark = false; function setWaterPalette() { const theme = pond.closest(".interaction-ripple-stage").dataset.rippleTheme; - waterTint = { + waterDark = document.documentElement.dataset.theme === "dark"; + waterTint = (waterDark ? { + sky: [33, 68, 84], + mint: [43, 75, 47], + rose: [88, 47, 76], + } : { sky: [110, 165, 185], mint: [123, 161, 134], rose: [185, 139, 161], - }[theme]; + })[theme]; } function paintWater() { if (!waterContext || !waterWidth) return; @@ -1443,8 +1449,8 @@ for (let i = 0; i < heights.length; i++) { const crest = heights[i]; for (let c = 0; c < 3; c++) - pixels[i * 4 + c] = crest >= 0 ? 255 : waterTint[c]; - pixels[i * 4 + 3] = Math.min(72, Math.abs(crest) * 8); + pixels[i * 4 + c] = crest >= 0 ? (waterDark ? waterTint[c] + 95 : 255) : waterTint[c]; + pixels[i * 4 + 3] = Math.min(waterDark ? 48 : 72, Math.abs(crest) * 8); } waterContext.putImageData(waterPixels, 0, 0); } @@ -1576,6 +1582,10 @@ if (!event.detail) disturbWater(); }); if (pond) new ResizeObserver(sizeWater).observe(pond); + if (pond) new MutationObserver(() => { + setWaterPalette(); + paintWater(); + }).observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] }); document.querySelectorAll("[data-ripple-colour]").forEach((button) => { button.addEventListener("click", () => { pond.closest(".interaction-ripple-stage").dataset.rippleTheme = diff --git a/scripts/playground-theme-checks.mjs b/scripts/playground-theme-checks.mjs new file mode 100644 index 0000000..fbde39b --- /dev/null +++ b/scripts/playground-theme-checks.mjs @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; + +export async function playgroundThemeChecks(page, load) { + await page.setViewportSize({ width: 1440, height: 1000 }); + await load(page, "/play-lab#canvas"); + const toggle = page.locator(".theme-toggle"); + const setTheme = async dark => { + if (await toggle.getAttribute("aria-pressed") !== String(dark)) await toggle.dispatchEvent("click"); + }; + const luminance = color => { + const rgb = color.match(/[\d.]+/g).slice(0, 3).map(Number).map(v => { + v /= 255; return v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4; + }); + return rgb[0] * .2126 + rgb[1] * .7152 + rgb[2] * .0722; + }; + for (const dark of [false, true]) { + await setTheme(dark); + await page.waitForTimeout(300); + const tabs = await page.locator(".play-lab-switch a").evaluateAll(links => links.map(link => ({ + color: getComputedStyle(link).color, + background: getComputedStyle(link).backgroundColor === "rgba(0, 0, 0, 0)" + ? getComputedStyle(link.parentElement).backgroundColor : getComputedStyle(link).backgroundColor, + }))); + for (const tab of tabs) { + const values = [luminance(tab.color), luminance(tab.background)].sort((a, b) => b - a); + assert((values[0] + .05) / (values[1] + .05) >= 4.5, "Both section tabs remain readable"); + } + for (const width of [320, 390, 1440]) { + await page.setViewportSize({ width, height: 1000 }); + if (await page.locator("[data-playground-view]").getAttribute("aria-pressed") !== "true") + await page.locator("[data-playground-view]").click(); + await page.locator("[data-calendar-today]").click(); + const circles = await page.locator(".calendar-navigation button, .calendar-grid button").evaluateAll(buttons => buttons.map(button => { + const rect = button.getBoundingClientRect(); + return { width: rect.width, height: rect.height, radius: getComputedStyle(button).borderRadius }; + })); + assert(circles.every(c => Math.abs(c.width - c.height) < .05 && c.radius === "50%"), `${width} ${dark}: calendar controls are circles`); + assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)); + } + } + const cards = await page.locator("[data-playground-card]").evaluateAll(cards => cards.map(card => ({ + name: card.dataset.widgetId, background: getComputedStyle(card).backgroundColor, + }))); + for (const card of cards) assert(luminance(card.background) < .1, `${card.name} has a dark surface`); + const text = await page.locator(".playground-calendar > p, .playground-calendar [aria-pressed=true], .calculator-display, .mood-options button").evaluateAll(elements => elements.map(el => { + let parent = el; + while (parent && getComputedStyle(parent).backgroundColor === "rgba(0, 0, 0, 0)") parent = parent.parentElement; + return { color: getComputedStyle(el).color, background: getComputedStyle(parent).backgroundColor }; + })); + for (const item of text) { + const levels = [luminance(item.color), luminance(item.background)].sort((a, b) => b - a); + assert((levels[0] + .05) / (levels[1] + .05) >= 4.5, "New widget text remains readable in dark mode"); + } + await page.locator("[data-playground-view]").click(); + for (const color of ["blue", "pink", "peach", "cream", "green", "lilac"]) { + await page.locator(`[data-playground-color="${color}"]`).click(); + assert(luminance(await page.locator(".playground-board").evaluate(el => getComputedStyle(el).backgroundColor)) < .1, `${color} canvas has a dark counterpart`); + } + await page.locator("[data-playground-background-reset]").click(); + assert.equal(await page.locator(".playground-board").getAttribute("data-canvas-color"), "blue"); + await page.locator("#interactions-tab").click(); + for (const color of ["sky", "mint", "rose"]) { + await page.locator(`[data-ripple-colour="${color}"]`).click(); + const stages = await page.locator(".playground-experiment-stage").evaluateAll(els => els.map(el => getComputedStyle(el).backgroundColor)); + assert(stages.every(color => luminance(color) < .1), "Every interaction stage stays dark with each ripple palette"); + } + await page.emulateMedia({ reducedMotion: "reduce" }); + await setTheme(false); + const pond = page.locator("[data-ripple-pond]"), water = page.locator("[data-ripple-water]"); + await pond.click(); + const lightWave = await water.evaluate(canvas => canvas.toDataURL()); + await setTheme(true); + await page.waitForFunction(previous => document.querySelector("[data-ripple-water]").toDataURL() !== previous, lightWave); + await page.emulateMedia({ reducedMotion: "no-preference" }); + await setTheme(false); +} diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 68c30d7..eda8df3 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -10,6 +10,7 @@ import { refinementChecks } from "./refinement-checks.mjs"; import { conversationTiming, canvasMotion, canvasBounds } from "./motion-checks.mjs"; import { dotChecks } from "./dot-checks.mjs"; import { playgroundWidgetChecks } from "./playground-widget-checks.mjs"; +import { playgroundThemeChecks } from "./playground-theme-checks.mjs"; const args = process.argv.slice(2); const option = (name, fallback) => @@ -1457,7 +1458,7 @@ try { ], [ "zoomed canvas, tab isolation and water feedback", - async () => { await canvasMotion(page, load); await canvasBounds(page, load); await playgroundWidgetChecks(page, load); }, + async () => { await canvasMotion(page, load); await canvasBounds(page, load); await playgroundWidgetChecks(page, load); await playgroundThemeChecks(page, load); }, ], ["game start, pause, reset and keyboard", () => game(page)], ["clipboard success and denial", () => clipboard(browser)], From 1305262e37bc404a830732cf5f156ce3486f37c1 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Sun, 13 Sep 2026 17:19:29 +0530 Subject: [PATCH 15/22] fix(theme): theme card artwork and apply preferences before render --- 404.html | 7 +- about.html | 7 +- blog.html | 7 +- ...er-your-desktop-and-a-single-url-488m.html | 7 +- ...uide-to-development-and-features-4gpf.html | 7 +- blog/do-you-know-llama--3c2d.html | 7 +- blog/epic-games-membership-on-github-3ml.html | 7 +- blog/introducing-markdown-viewer-v20-com.html | 7 +- blog/llama-2-ggml-medical-chatbot-5043.html | 7 +- ...encvthe-enchanted-palette-part-2-424k.html | 7 +- ...cvunleashing-the-magic-of-images-1kdb.html | 7 +- blog/portfolio-thisis-developer-56bf.html | 7 +- ...rd-and-process-audio-with-python-2a3b.html | 7 +- ...github-commits-with-verification-3hja.html | 7 +- docs/design-system.md | 4 + editorial.css | 53 +++++- index.html | 7 +- package.json | 1 + play-lab.html | 7 +- project.html | 7 +- script.js | 17 +- scripts/build.mjs | 8 +- scripts/test-theme.mjs | 15 ++ scripts/theme-checks.mjs | 156 ++++++++++++++++++ scripts/verify.mjs | 2 + style.css | 47 ++++++ theme.js | 13 ++ work.html | 7 +- work/ams.html | 7 +- work/body-language.html | 7 +- work/csv-chatbot.html | 7 +- work/enterprise.html | 7 +- work/markdown-viewer.html | 7 +- work/medical-chatbot.html | 7 +- work/medichain.html | 7 +- work/news-scraper.html | 7 +- work/notemarker.html | 7 +- work/sei-sangeet-bangla.html | 7 +- work/simon.html | 7 +- work/sketchflow.html | 7 +- work/taskflow.html | 7 +- 41 files changed, 426 insertions(+), 107 deletions(-) create mode 100644 scripts/test-theme.mjs create mode 100644 scripts/theme-checks.mjs create mode 100644 theme.js diff --git a/404.html b/404.html index f3d7844..6629143 100644 --- a/404.html +++ b/404.html @@ -7,6 +7,7 @@ + @@ -27,12 +28,12 @@ - + - + - +

404

A small detour.

This page took a wrong turn.
Jump a few bugs, then find your way back.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file diff --git a/about.html b/about.html index 467cd13..88e465f 100644 --- a/about.html +++ b/about.html @@ -7,6 +7,7 @@ + @@ -27,12 +28,12 @@ - + - + - +

Hey, I’m Baivab.

I build useful software, follow my curiosity,
and keep learning along the way.

Hover or select a photo to bring it forward. Use the arrow keys to explore, and Escape to put it back.

I like turning everyday friction into useful software. A better place to write Markdown. A way to trace a medicine’s journey. A test that catches the next broken workflow.

I’m Baivab, a software developer building open-source tools, thoughtful web experiences, and the tests that keep them reliable.

My work connects Java and JavaScript development with a growing practice in test automation. I care about the whole journey: how something works, how it feels to use, and what happens when it breaks.

CURRENTLY

West Bengal, India

Building tools. Learning in public.

THINGS I ENJOY WORKING WITH

Open sourceJavaScriptJavaTest automationBrowser toolsComputer scienceDeveloper experienceCreative coding
Creative codingDeveloper experienceComputer scienceBrowser toolsTest automationJavaJavaScriptOpen source

SELECTED TOOLS

JavaScriptPythonJavaSeleniumTensorFlowOpenCVDockerJenkinsGitCloudflareVercelFigma

What I enjoy working on

I’m drawn to the small problems that keep getting in the way: a document that needs a better workspace, information that needs to move between people, or a workflow that needs reliable tests.

That has taken me from Markdown Viewer and NoteMarker to MediChain, academic applications and private enterprise tools.

My process

I start with the workflow: what someone needs to do, where the friction is, and what a useful first version would look like. Then I build, test and refine the details.

My Java and Selenium training has made testing part of that process. I want the interface and the behavior behind it to make sense together.

Learning in public

My public repositories and blog are a record of that learning. There are polished tools, team prototypes and earlier experiments. Each has something that led to the next one.

Read my notes

Experience & learning

2024 — ongoing

Building in the open

Independent projects · Open source

Developing Markdown Viewer and other tools, working through product details from responsive interfaces to document rendering and collaboration.

Explore the source

Training completed

Java & Selenium QA automation

Hands-on SDET training

UI and API testing with Selenium WebDriver, TestNG, JUnit, Cucumber, Postman, and Jenkins. Practiced Page Object Model and repeatable test workflows.

Oct 2021 — May 2025

B.Tech, Computer Science & Engineering

JIS College of Engineering

Graduated with a CGPA of 9.15/10. Built a foundation in object-oriented programming, data structures, algorithms, databases, and the software development lifecycle.

PRIVATE FREELANCE WORK

Enterprise applications & automation

I have developed internal enterprise applications and business automation tools for private clients, supporting everyday operations, collaboration and documentation.

Private work overview

GitHub FoundationsNov 2024

Java Programming · UdemyMay 2025

Data Structures & Algorithms · UdemySep 2024

An idea, an opportunity, or just a hello?

Build with me.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file diff --git a/blog.html b/blog.html index 5cb59eb..3ce2954 100644 --- a/blog.html +++ b/blog.html @@ -7,6 +7,7 @@ + @@ -27,12 +28,12 @@ - + - + - +

ENGINEERING & IDEAS

Built with care.
Shared in detail.

Inside the software I build: product decisions, practical guides and lessons from working with code.

11 posts

Markdown Viewer: one workspace across browser and desktop

A closer look at document tabs, desktop delivery, GitHub imports and sharing documents through a URL.

Baivab Sarkar

Inside Markdown Viewer 2.0

Extending a live-preview editor with diagrams, mathematical notation, syntax highlighting and a more complete writing interface.

Baivab Sarkar

Managing public membership in large GitHub organizations

A practical guide to changing organization membership visibility when the member directory is difficult to navigate.

Baivab Sarkar

Signing Git commits with GPG

Generate a GPG key, configure Git signing and verify signatures in your commit history.

Baivab Sarkar

Building a Markdown viewer for the web

The foundations of a browser-based Markdown editor: document input, rendered output and a practical setup workflow.

Baivab Sarkar

Question answering over CSV data with Llama 2

An experimental retrieval workflow that combines CSV documents, a pretrained language model and a chat interface.

Baivab Sarkar

Building a document-retrieval prototype with Llama 2

A Python experiment connecting reference passages to a pretrained language model through a conversational interface.

Baivab Sarkar

Image processing with OpenCV: filters, contours and histograms

Notebook examples covering resizing, morphology, filtering, thresholding and histogram analysis.

Baivab Sarkar

Getting started with images in OpenCV

A compact notebook-based introduction to loading and working with images using Python and OpenCV.

Baivab Sarkar

Recording and analysing audio with Python

Python examples for recording audio, loading files, plotting waveforms and generating spectrograms.

Baivab Sarkar

Connecting a portfolio contact form to Google Sheets

A historical implementation using browser JavaScript and Google Apps Script to store contact-form submissions.

Baivab Sarkar

An idea, an opportunity, or just a hello?

Build with me.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file diff --git a/blog/a-markdown-editor-that-lives-in-your-browser-your-desktop-and-a-single-url-488m.html b/blog/a-markdown-editor-that-lives-in-your-browser-your-desktop-and-a-single-url-488m.html index 906266d..49b0ab4 100644 --- a/blog/a-markdown-editor-that-lives-in-your-browser-your-desktop-and-a-single-url-488m.html +++ b/blog/a-markdown-editor-that-lives-in-your-browser-your-desktop-and-a-single-url-488m.html @@ -7,6 +7,7 @@ + @@ -27,13 +28,13 @@ - + - + - +

Projects

Markdown Viewer: one workspace across browser and desktop

A closer look at document tabs, desktop delivery, GitHub imports and sharing documents through a URL.

The latest Markdown Viewer rebuild brings document management, rendering and sharing into one workspace. This article explains the changes to multi-tab sessions, desktop delivery, GitHub imports and URL-based sharing, alongside the decisions behind them.

diff --git a/blog/building-a-markdown-viewer-web-application-a-comprehensive-guide-to-development-and-features-4gpf.html b/blog/building-a-markdown-viewer-web-application-a-comprehensive-guide-to-development-and-features-4gpf.html index e9b971e..a926ba6 100644 --- a/blog/building-a-markdown-viewer-web-application-a-comprehensive-guide-to-development-and-features-4gpf.html +++ b/blog/building-a-markdown-viewer-web-application-a-comprehensive-guide-to-development-and-features-4gpf.html @@ -7,6 +7,7 @@ + @@ -27,13 +28,13 @@ - + - + - +

Projects

Building a Markdown viewer for the web

The foundations of a browser-based Markdown editor: document input, rendered output and a practical setup workflow.

A Markdown viewer connects a plain-text writing surface to rendered output. This guide introduces the format, walks through the features of the original web application and shows how to run the project locally.

diff --git a/blog/do-you-know-llama--3c2d.html b/blog/do-you-know-llama--3c2d.html index 9e6f6ad..77a0212 100644 --- a/blog/do-you-know-llama--3c2d.html +++ b/blog/do-you-know-llama--3c2d.html @@ -7,6 +7,7 @@ + @@ -27,13 +28,13 @@ - + - + - +

AI & data

Question answering over CSV data with Llama 2

An experimental retrieval workflow that combines CSV documents, a pretrained language model and a chat interface.

This project explores question answering over CSV data using a pretrained, quantized Llama 2 model. Retrieval supplies relevant document context; the project does not train or fine-tune the model on the uploaded data.

Llama2 CSV ChatBot

diff --git a/blog/epic-games-membership-on-github-3ml.html b/blog/epic-games-membership-on-github-3ml.html index bee809c..5fce08f 100644 --- a/blog/epic-games-membership-on-github-3ml.html +++ b/blog/epic-games-membership-on-github-3ml.html @@ -7,6 +7,7 @@ + @@ -27,13 +28,13 @@ - + - + - +

Git & tooling

Managing public membership in large GitHub organizations

A practical guide to changing organization membership visibility when the member directory is difficult to navigate.

Organization membership visibility determines whether an organization appears on your public GitHub profile. This guide documents a workflow for changing that setting in a large organization, using Epic Games as the example.

GitHub Fun Gif

diff --git a/blog/introducing-markdown-viewer-v20-com.html b/blog/introducing-markdown-viewer-v20-com.html index 0ced274..38e3038 100644 --- a/blog/introducing-markdown-viewer-v20-com.html +++ b/blog/introducing-markdown-viewer-v20-com.html @@ -7,6 +7,7 @@ + @@ -27,13 +28,13 @@ - + - + - +

Projects

Inside Markdown Viewer 2.0

Extending a live-preview editor with diagrams, mathematical notation, syntax highlighting and a more complete writing interface.

Markdown Viewer 2.0 expands the original split-pane editor with diagrams, mathematical notation, syntax highlighting and an updated interface. This release overview covers the features added to support a broader technical-writing workflow.

diff --git a/blog/llama-2-ggml-medical-chatbot-5043.html b/blog/llama-2-ggml-medical-chatbot-5043.html index 69480e5..863f886 100644 --- a/blog/llama-2-ggml-medical-chatbot-5043.html +++ b/blog/llama-2-ggml-medical-chatbot-5043.html @@ -7,6 +7,7 @@ + @@ -27,13 +28,13 @@ - + - + - +

AI & data

Building a document-retrieval prototype with Llama 2

A Python experiment connecting reference passages to a pretrained language model through a conversational interface.

This prototype retrieves passages from a medical reference document and supplies them to a pretrained, quantized Llama 2 model. It is an engineering experiment in retrieval and interface design. It has not been clinically validated and is not intended for medical decision-making.

diff --git a/blog/opencvthe-enchanted-palette-part-2-424k.html b/blog/opencvthe-enchanted-palette-part-2-424k.html index e61dd80..3c007dd 100644 --- a/blog/opencvthe-enchanted-palette-part-2-424k.html +++ b/blog/opencvthe-enchanted-palette-part-2-424k.html @@ -7,6 +7,7 @@ + @@ -27,13 +28,13 @@ - + - + - +

AI & data

Image processing with OpenCV: filters, contours and histograms

Notebook examples covering resizing, morphology, filtering, thresholding and histogram analysis.

This collection extends the basic image-loading workflow with common OpenCV processing operations. The examples cover resizing, morphology, contours, filtering, thresholding and histogram analysis, with links to the corresponding notebook.

Embark on a Visual Journey: Explore the OpenCV Chronicles in my GitHub Repository! #OpenCVMagic #ImageAlchemy #CodeCanvas
Source GitHub: ThisIs-Developer/Python/OpenCV/Image Processing
diff --git a/blog/opencvunleashing-the-magic-of-images-1kdb.html b/blog/opencvunleashing-the-magic-of-images-1kdb.html index 6d468e3..5bcad63 100644 --- a/blog/opencvunleashing-the-magic-of-images-1kdb.html +++ b/blog/opencvunleashing-the-magic-of-images-1kdb.html @@ -7,6 +7,7 @@ + @@ -27,13 +28,13 @@ - + - + - +

AI & data

Getting started with images in OpenCV

A compact notebook-based introduction to loading and working with images using Python and OpenCV.

This notebook collection introduces working with images in Python using OpenCV. Install the package, open the linked notebook and follow the accompanying examples to explore the image-processing workflow.

diff --git a/blog/portfolio-thisis-developer-56bf.html b/blog/portfolio-thisis-developer-56bf.html index 5316e98..0214952 100644 --- a/blog/portfolio-thisis-developer-56bf.html +++ b/blog/portfolio-thisis-developer-56bf.html @@ -7,6 +7,7 @@ + @@ -27,13 +28,13 @@ - + - + - +

Notes

Connecting a portfolio contact form to Google Sheets

A historical implementation using browser JavaScript and Google Apps Script to store contact-form submissions.

An earlier version of my portfolio used browser JavaScript and Google Apps Script to send contact-form submissions to a spreadsheet. This article records that implementation and its interface states; it describes the older project, rather than the contact flow on this website.

Website isLive:baivabsarkar-old.vercel.app

diff --git a/blog/record-and-process-audio-with-python-2a3b.html b/blog/record-and-process-audio-with-python-2a3b.html index 9f059fa..e966253 100644 --- a/blog/record-and-process-audio-with-python-2a3b.html +++ b/blog/record-and-process-audio-with-python-2a3b.html @@ -7,6 +7,7 @@ + @@ -27,13 +28,13 @@ - + - + - +

AI & data

Recording and analysing audio with Python

Python examples for recording audio, loading files, plotting waveforms and generating spectrograms.

These examples bring together audio recording, playback and analysis in Python. The code covers loading audio, examining sample data, plotting waveforms and generating spectrograms with Librosa and Matplotlib.

diff --git a/blog/secure-your-github-commits-with-verification-3hja.html b/blog/secure-your-github-commits-with-verification-3hja.html index f675db3..39bb29b 100644 --- a/blog/secure-your-github-commits-with-verification-3hja.html +++ b/blog/secure-your-github-commits-with-verification-3hja.html @@ -7,6 +7,7 @@ + @@ -27,13 +28,13 @@ - + - + - +

Git & tooling

Signing Git commits with GPG

Generate a GPG key, configure Git signing and verify signatures in your commit history.

Signed commits attach a cryptographic signature to a Git commit. This walkthrough covers generating a GPG key, adding its public component to GitHub, configuring Git and checking the resulting signatures.

How to Set Up a GPG Key for Signed Git Commits: A Step-by-Step Guide

diff --git a/docs/design-system.md b/docs/design-system.md index 820d25a..8573187 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -19,6 +19,10 @@ The portfolio uses a warm dotted canvas, expressive serif type, translucent past The six folder tints are #b8f0d8, #d4c9f5, #b8cef5, #f5d4b8, #f2a65a and #c8e6c0. Folder labels use dark ink for readable contrast. Each folder has three fanned sheets, a frosted front starting at 32% height, and a 400:340 proportion. +Dark mode gives each folder a deep version of its original tint, lighter labels, dark sheet frames, and quieter glass highlights. Actual project screenshots retain their original colours. Shared Blog and private-work illustrations use matching dark backgrounds, sheets, accent colours, and subtle highlights; the same palettes also apply to home-page writing cards and article covers. Light and print artwork keep their original palettes. + +Every generated page loads the small synchronous `theme.js` script in its head before stylesheets and content. It reads `portfolio-theme`, applies the root theme and browser toolbar colour, and defaults to light if storage is unavailable. The deferred navigation script adopts that theme without resetting it and refreshes saved preferences when a page returns from the browser's back/forward cache. The bootstrap is self-hosted to work with the existing Content Security Policy. Run `npm run build` and `npm run test:theme` to verify dark first frames while the main script is delayed, navigation, card palettes, and storage failure behaviour against the built site. + ## Page composition - Home: centered 88svh introduction, six featured project folders, timed capabilities, selected writing, personal cards and dark contact/game ending. diff --git a/editorial.css b/editorial.css index a1627ce..b2cfdfc 100644 --- a/editorial.css +++ b/editorial.css @@ -3,12 +3,17 @@ --art-bg: #e6ebfa; --art-ink: #4e62ad; --art-shade: #cad5f1; + --art-sheet: #ffffffbf; + --art-glow: #ffffffa8; + --art-orbit: #ffffff38; + --art-shadow: #2932540a; + --art-check: #fff; isolation: isolate; height: auto; min-height: 210px; aspect-ratio: 1.5; background: - radial-gradient(ellipse at 80% 15%, #ffffffa8, transparent 65%), + radial-gradient(ellipse at 80% 15%, var(--art-glow), transparent 65%), var(--art-bg); color: var(--art-ink); border-radius: 18px; @@ -42,6 +47,43 @@ --art-ink: #8a7350; --art-shade: #e4d6be; } +@media screen { + :root[data-theme="dark"] .editorial-art { + --art-bg: #222a3d; + --art-ink: #b0bfee; + --art-shade: #354366; + --art-sheet: color-mix(in srgb, var(--art-bg) 75%, var(--art-ink)); + --art-glow: #ffffff06; + --art-orbit: #ffffff05; + --art-shadow: #00000026; + --art-check: var(--art-bg); + } + :root[data-theme="dark"] .editorial-art.art-git { + --art-bg: #202f29; + --art-ink: #a1d4b7; + --art-shade: #355744; + } + :root[data-theme="dark"] .editorial-art.art-ai { + --art-bg: #2d263c; + --art-ink: #c7b5eb; + --art-shade: #4b3c64; + } + :root[data-theme="dark"] .editorial-art.art-vision { + --art-bg: #372922; + --art-ink: #e6b393; + --art-shade: #62432f; + } + :root[data-theme="dark"] .editorial-art.art-audio { + --art-bg: #21303a; + --art-ink: #a6cfe4; + --art-shade: #355565; + } + :root[data-theme="dark"] .editorial-art.art-web { + --art-bg: #312c23; + --art-ink: #d8c29a; + --art-shade: #574934; + } +} .editorial-art > svg { width: 82%; max-height: 90%; @@ -49,8 +91,8 @@ transition: transform 0.45s var(--out); } .editorial-art .art-sheet { - fill: #ffffffbf; - filter: drop-shadow(0 8px 8px #2932540a); + fill: var(--art-sheet); + filter: drop-shadow(0 8px 8px var(--art-shadow)); } .editorial-art .art-back { fill: var(--art-shade); @@ -67,6 +109,9 @@ stroke-linecap: round; stroke-linejoin: round; } +.editorial-art .art-check { + stroke: var(--art-check); +} .editorial-art .art-connector { stroke: currentColor; stroke-width: 7; @@ -83,7 +128,7 @@ height: auto; aspect-ratio: 1; border-radius: 50%; - background: #ffffff38; + background: var(--art-orbit); transform: none; z-index: 0; } diff --git a/index.html b/index.html index 3b99fad..30e5437 100644 --- a/index.html +++ b/index.html @@ -7,6 +7,7 @@ + @@ -27,13 +28,13 @@ - + - + - +

SOFTWARE DEVELOPMENT & TEST AUTOMATION

Baivab Sarkar

I build thoughtful software
for real-world problems.

a little curious. a lot of code.

SCROLL TO EXPLORE

A FEW THINGS I’VE BUILT

Featured projects.

Open the folders. There’s good stuff inside.

diff --git a/package.json b/package.json index 3dc7739..807cb0f 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "test:content": "node scripts/test-content.mjs", "test:ask": "node scripts/test-quick-ask.mjs", "test:responsive": "node scripts/responsive-audit.mjs", + "test:theme": "node scripts/test-theme.mjs", "post:new": "node scripts/new-post.mjs", "test:performance": "node scripts/performance.mjs", "check": "node scripts/build.mjs --check" diff --git a/play-lab.html b/play-lab.html index 25a7324..10d3f6d 100644 --- a/play-lab.html +++ b/play-lab.html @@ -7,6 +7,7 @@ + @@ -27,13 +28,13 @@ - + - + - +

Play Lab

diff --git a/project.html b/project.html index b987cce..06573aa 100644 --- a/project.html +++ b/project.html @@ -7,6 +7,7 @@ + @@ -27,12 +28,12 @@ - + - + - +

SELECTED WORK

A closer look at what
I've built.

12 projects

01—06 / FEATURED PROJECTS

07—12 / MORE WORK

Project archive.

PRIVATE FREELANCE WORK

Behind the scenes.

I have developed internal enterprise applications and business automation tools for private clients, supporting everyday operations, collaboration and documentation.

Confidentiality notice. These applications were developed as part of private freelance and enterprise engagements. Source code, system architecture, client data and certain project details cannot be disclosed due to confidentiality requirements.

An idea, an opportunity, or just a hello?

Build with me.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file diff --git a/script.js b/script.js index 4f29b07..dca7a0a 100644 --- a/script.js +++ b/script.js @@ -132,13 +132,16 @@ if (themeToggle) { .querySelector('meta[name="theme-color"]') ?.setAttribute("content", dark ? "#18191c" : "#f5f4f0"); } - let saved; - try { - saved = localStorage.getItem("portfolio-theme"); - } catch { - /* The default theme works without storage. */ - } - applyTheme(saved === "dark"); + // Adopt the theme already applied by the head script before first paint. + applyTheme(document.documentElement.dataset.theme === "dark"); + window.addEventListener("pageshow", (event) => { + if (!event.persisted) return; + try { + applyTheme(localStorage.getItem("portfolio-theme") === "dark"); + } catch { + /* Keep this page's theme when storage is unavailable. */ + } + }); themeToggle.hidden = false; themeToggle.addEventListener("click", () => { const dark = themeToggle.getAttribute("aria-pressed") !== "true"; diff --git a/scripts/build.mjs b/scripts/build.mjs index c1496d7..4411d2d 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -99,6 +99,7 @@ function metadata(options = {}) { ${options.noindex ? '' : ""} + @@ -119,13 +120,13 @@ function metadata(options = {}) { - + ${options.page ? '' : ""} - + ${!options.page ? '' : ""} - + ${options.page ? '' : ""} `.replace( /\n[ \t]+\n/g, @@ -211,6 +212,7 @@ if (!check) { const publicFiles = [ ...Object.keys(outputs), "style.css", + "theme.js", "script.js", "game.js", "pages.css", diff --git a/scripts/test-theme.mjs b/scripts/test-theme.mjs new file mode 100644 index 0000000..21ccc6f --- /dev/null +++ b/scripts/test-theme.mjs @@ -0,0 +1,15 @@ +import { chromium } from "playwright"; +import { fileURLToPath } from "node:url"; +import { startServer } from "./serve.mjs"; +import { themeChecks } from "./theme-checks.mjs"; + +const preview = await startServer({ port: 0, root: fileURLToPath(new URL("../dist/", import.meta.url)) }); +let browser; +try { + browser = await chromium.launch(); + await themeChecks(browser, preview.url); + console.log("Theme checks passed: early rendering, navigation, artwork, folders, responsive layouts, and blocked storage."); +} finally { + await browser?.close(); + preview.server.close(); +} diff --git a/scripts/theme-checks.mjs b/scripts/theme-checks.mjs new file mode 100644 index 0000000..544ce13 --- /dev/null +++ b/scripts/theme-checks.mjs @@ -0,0 +1,156 @@ +import assert from "node:assert/strict"; + +const luminance = (color) => { + const channels = color.match(/[\d.]+/g).slice(0, 3).map(Number).map((v) => { + v /= 255; + return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; + }); + return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722; +}; +const contrast = (a, b) => { + const [high, low] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (high + 0.05) / (low + 0.05); +}; + +export async function themeChecks(browser, url) { + const context = await browser.newContext({ + viewport: { width: 1440, height: 1000 }, + colorScheme: "light", // A saved choice must beat the operating system theme. + storageState: { cookies: [], origins: [{ origin: url, localStorage: [ + { name: "portfolio-theme", value: "dark" }, + ] }] }, + }); + try { + await context.addInitScript(() => { + window.themeFrames = []; + const sample = () => { + const surface = document.querySelector(".page-surface"); + if (surface && window.themeFrames.length < 30) { + window.themeFrames.push({ + theme: document.documentElement.dataset.theme, + scheme: getComputedStyle(document.documentElement).colorScheme, + background: getComputedStyle(surface).backgroundColor, + }); + } + requestAnimationFrame(sample); + }; + requestAnimationFrame(sample); + }); + const page = await context.newPage(); + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + page.on("console", (message) => { + if (/violates.*Content Security Policy|Refused to execute/i.test(message.text())) + errors.push(message.text()); + }); + await page.goto(`${url}/blog`, { waitUntil: "networkidle" }); + const article = await page.locator(".journal-card").first().getAttribute("href"); + const routes = ["/", "/blog", "/work", article, "/work/markdown-viewer", "/about", + "/play-lab#canvas", "/play-lab#interactions", "/missing-theme-check"]; + for (const route of routes) { + // Force a fresh document even when two routes differ only by their hash. + await page.goto("about:blank"); + let release; + const hold = new Promise((resolve) => { release = resolve; }); + const handler = async (request) => { await hold; await request.continue(); }; + await page.route(/\/script\.js(?:\?|$)/, handler); + try { + await page.goto(`${url}${route}`, { waitUntil: "commit" }); + await page.waitForFunction(() => window.themeFrames?.length >= 3); + assert.equal(await page.locator(".theme-toggle").getAttribute("hidden"), "", + `${route}: main script is still delayed`); + const frames = await page.evaluate(() => window.themeFrames); + assert(frames.every((frame) => frame.theme === "dark" && frame.scheme === "dark" + && frame.background === "rgb(24, 25, 28)"), + `${route}: content is dark from the first rendered frame, before deferred initialization`); + assert.equal(await page.locator('meta[name="theme-color"]').getAttribute("content"), "#18191c"); + } finally { + release(); + await page.waitForLoadState("networkidle"); + await page.unroute(/\/script\.js(?:\?|$)/, handler); + } + assert.equal(await page.locator(".theme-toggle").getAttribute("aria-pressed"), "true"); + } + + // Real navigation, reload, back/forward, and switching in both directions. + await page.goto(`${url}/blog`, { waitUntil: "networkidle" }); + await page.locator(".journal-card").first().click(); + await page.waitForLoadState("networkidle"); + assert.equal(await page.locator("html").getAttribute("data-theme"), "dark"); + await page.reload({ waitUntil: "networkidle" }); + assert.equal(await page.locator("html").getAttribute("data-theme"), "dark"); + await page.locator(".theme-toggle").dispatchEvent("click"); + await page.goBack({ waitUntil: "networkidle" }); + assert.equal(await page.locator("html").getAttribute("data-theme"), "light"); + assert.equal(await page.locator(".theme-toggle").getAttribute("aria-pressed"), "false"); + await page.locator(".theme-toggle").dispatchEvent("click"); + await page.goForward({ waitUntil: "networkidle" }); + assert.equal(await page.locator("html").getAttribute("data-theme"), "dark"); + + for (const width of [390, 1440]) { + await page.setViewportSize({ width, height: 1000 }); + for (const route of ["/blog", "/work", "/", article]) { + await page.goto(`${url}${route}`, { waitUntil: "networkidle" }); + for (const dark of [true, false]) { + if (await page.locator(".theme-toggle").getAttribute("aria-pressed") !== String(dark)) + await page.locator(".theme-toggle").dispatchEvent("click"); + const artwork = await page.locator(".editorial-art").evaluateAll((elements) => elements.map((el) => ({ + background: getComputedStyle(el).backgroundColor, + color: getComputedStyle(el).color, + sheets: [...el.querySelectorAll(".art-sheet")].map((sheet) => getComputedStyle(sheet).fill), + }))); + assert(artwork.length, `${route}: artwork exists`); + for (const art of artwork) { + assert(dark ? luminance(art.background) < 0.1 : luminance(art.background) > 0.7, + `${route}: illustration background matches ${dark ? "dark" : "light"} theme`); + if (dark) { + assert(contrast(art.color, art.background) >= 4.5, "Artwork labels retain contrast"); + for (const fill of art.sheets) { + // Browsers serialize color-mix as color(srgb ...) with normalized channels. + const rgb = fill.startsWith("color(srgb") + ? `rgb(${fill.match(/[\d.]+/g).slice(0, 3).map((v) => Number(v) * 255).join(", ")})` + : fill; + assert(luminance(rgb) < 0.2, "Illustrated sheets use dark surfaces"); + } + } + } + if (dark) { + const folders = await page.locator(".folder").evaluateAll((elements) => elements.map((el) => ({ + background: getComputedStyle(el.querySelector(".folder-back")).backgroundColor, + color: getComputedStyle(el.querySelector(".folder-front")).color, + filter: getComputedStyle(el.querySelector("img")).filter, + }))); + for (const folder of folders) { + assert(luminance(folder.background) < 0.1, "Project folders use dark tints"); + assert(contrast(folder.color, folder.background) >= 4.5, "Folder text remains readable"); + assert.equal(folder.filter, "none", "Original project screenshots keep their colours"); + } + } + assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), + `${route} at ${width}: no horizontal overflow`); + } + await page.locator(".theme-toggle").dispatchEvent("click"); + } + } + assert.deepEqual(errors, [], "Theme initialization works under the production CSP without browser errors"); + } finally { + await context.close(); + } + + const blocked = await browser.newContext(); + try { + await blocked.addInitScript(() => { + Object.defineProperty(window, "localStorage", { get() { throw new Error("Storage unavailable"); } }); + }); + const page = await blocked.newPage(); + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto(`${url}/blog`, { waitUntil: "networkidle" }); + assert.equal(await page.locator("html").getAttribute("data-theme"), "light"); + await page.locator(".theme-toggle").dispatchEvent("click"); + assert.equal(await page.locator("html").getAttribute("data-theme"), "dark"); + assert.deepEqual(errors, [], "Theme toggle still works when storage is unavailable"); + } finally { + await blocked.close(); + } +} diff --git a/scripts/verify.mjs b/scripts/verify.mjs index eda8df3..df095a6 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -11,6 +11,7 @@ import { conversationTiming, canvasMotion, canvasBounds } from "./motion-checks. import { dotChecks } from "./dot-checks.mjs"; import { playgroundWidgetChecks } from "./playground-widget-checks.mjs"; import { playgroundThemeChecks } from "./playground-theme-checks.mjs"; +import { themeChecks } from "./theme-checks.mjs"; const args = process.argv.slice(2); const option = (name, fallback) => @@ -1447,6 +1448,7 @@ try { "theme persistence and unavailable storage", () => theme(page, browser), ], + ["theme before first paint and dark card artwork", () => themeChecks(browser, preview.url)], [ "project folders and capabilities", () => foldersAndCapabilities(page), diff --git a/style.css b/style.css index 97eaabc..ee1d635 100644 --- a/style.css +++ b/style.css @@ -640,6 +640,53 @@ input { width: 15px; height: 15px; } +@media screen { + :root[data-theme="dark"] .folder { + --tint: #243f36; + --folder-ink: #c4eddb; + } + :root[data-theme="dark"] .folder-2 { + --tint: #37304e; + --folder-ink: #e1d6fa; + } + :root[data-theme="dark"] .folder-3 { + --tint: #293b57; + --folder-ink: #d0e0fa; + } + :root[data-theme="dark"] .folder-4 { + --tint: #493528; + --folder-ink: #f5ddc7; + } + :root[data-theme="dark"] .folder-5 { + --tint: #4b3422; + --folder-ink: #f9d6af; + } + :root[data-theme="dark"] .folder-6 { + --tint: #2f402c; + --folder-ink: #d7edcd; + } + :root[data-theme="dark"] .folder-sheet { + background: var(--glass); + border-color: var(--glass); + } + :root[data-theme="dark"] .folder-front { + background: linear-gradient( + 155deg, + color-mix(in srgb, var(--tint) 92%, transparent), + color-mix(in srgb, var(--tint) 97%, transparent) + ); + border-color: rgb(255 255 255 / 0.12); + box-shadow: + inset 0 1px 0 #ffffff0d, + 0 6px 18px #00000026; + backdrop-filter: blur(22px) saturate(120%); + } + :root[data-theme="dark"] .folder-tags span, + :root[data-theme="dark"] .folder-arrow { + background: rgb(255 255 255 / 0.07); + border-color: rgb(255 255 255 / 0.12); + } +} .folder:hover .sheet-1, .folder:focus-visible .sheet-1 { transform: translate(-18%, -20%) rotate(-8deg); diff --git a/theme.js b/theme.js new file mode 100644 index 0000000..481ddff --- /dev/null +++ b/theme.js @@ -0,0 +1,13 @@ +// This small, parser-blocking script runs before stylesheets or page content. +(() => { + let dark = false; + try { + dark = localStorage.getItem("portfolio-theme") === "dark"; + } catch { + // Keep the default light theme when storage is unavailable. + } + document.documentElement.dataset.theme = dark ? "dark" : "light"; + document + .querySelector('meta[name="theme-color"]') + ?.setAttribute("content", dark ? "#18191c" : "#f5f4f0"); +})(); diff --git a/work.html b/work.html index b987cce..06573aa 100644 --- a/work.html +++ b/work.html @@ -7,6 +7,7 @@ + @@ -27,12 +28,12 @@ - + - + - +

SELECTED WORK

A closer look at what
I've built.

12 projects

01—06 / FEATURED PROJECTS

07—12 / MORE WORK

Project archive.

PRIVATE FREELANCE WORK

Behind the scenes.

I have developed internal enterprise applications and business automation tools for private clients, supporting everyday operations, collaboration and documentation.

Confidentiality notice. These applications were developed as part of private freelance and enterprise engagements. Source code, system architecture, client data and certain project details cannot be disclosed due to confidentiality requirements.

An idea, an opportunity, or just a hello?

Build with me.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file diff --git a/work/ams.html b/work/ams.html index f33805c..6af5750 100644 --- a/work/ams.html +++ b/work/ams.html @@ -7,6 +7,7 @@ + @@ -27,12 +28,12 @@ - + - + - +

Full-stack · Team project

AMS

Role-based attendance workflows for administrators, teachers and students.

Project
Team prototype
Period
2024
Built with
JavaScript, Spring, Redis
Academic Management System attendance dashboard on two mobile screens.

Overview

Role-based attendance workflows for administrators, teachers and students.

Contributed to a five-person academic management project.

The problem

Academic attendance records need coordinated access and reporting across institution roles.

My contribution

Contributed to a five-person academic management project.

JavaScriptSpringRedisPostgreSQLJPA

Inside the build

  • Role-specific attendance interfaces.
  • Attendance recording and report workflows.

Project notes

Team prototype

AMS is part of my work in full-stack · team project. The links above provide the available project and implementation details.

Next projectNoteMarker Extension

An idea, an opportunity, or just a hello?

Build with me.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file diff --git a/work/body-language.html b/work/body-language.html index 5974a55..4c34a5e 100644 --- a/work/body-language.html +++ b/work/body-language.html @@ -7,6 +7,7 @@ + @@ -27,12 +28,12 @@ - + - + - +

Computer vision · Notebook

Body Language Detection

A computer-vision experiment comparing landmark-based classification pipelines.

Project
Archived research experiment
Period
2023
Built with
Python, MediaPipe, OpenCV
Bar chart of labeled samples from the Body Language Detection experiment.

Overview

A computer-vision experiment comparing landmark-based classification pipelines.

Built notebooks for collecting labeled landmark data, training classifiers and exporting models.

The problem

Explore how video landmarks can be collected and used as classifier inputs.

My contribution

Built notebooks for collecting labeled landmark data, training classifiers and exporting models.

PythonMediaPipeOpenCVscikit-learnTensorFlow

Inside the build

  • Collect labeled landmark data from webcam or recorded video.
  • Compare scikit-learn pipelines and a TensorFlow Lite export.

Project notes

Archived research experiment

Body Language Detection is part of my work in computer vision · notebook. The links above provide the available project and implementation details.

Next projectMediChain

An idea, an opportunity, or just a hello?

Build with me.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file diff --git a/work/csv-chatbot.html b/work/csv-chatbot.html index d5422ce..0983ad0 100644 --- a/work/csv-chatbot.html +++ b/work/csv-chatbot.html @@ -7,6 +7,7 @@ + @@ -27,12 +28,12 @@ - + - + - +

AI · Retrieval experiment

Llama 2 GGML CSV Chatbot

Ask questions of uploaded CSV data using local model inference and vector retrieval.

Project
Research prototype
Period
2024
Built with
Python, LangChain, Streamlit
Llama 2 GGML CSV Chatbot — replaceable project image
Project image placeholder

Overview

Ask questions of uploaded CSV data using local model inference and vector retrieval.

Built a Streamlit prototype combining CSV ingestion, embedding search and a quantized Llama 2 model.

The problem

Exploring tabular data through natural language requires grounding responses in the uploaded file.

My contribution

Built a Streamlit prototype combining CSV ingestion, embedding search and a quantized Llama 2 model.

PythonLangChainStreamlitLlama 2FAISS

Inside the build

  • Chunk CSV data and index it with sentence-transformer embeddings and FAISS.
  • Retrieve relevant rows for a Llama 2 question-answering workflow.

Project notes

Research prototype

Llama 2 GGML CSV Chatbot is part of my work in ai · retrieval experiment. The links above provide the available project and implementation details.

Next projectNews Scraping Platform

An idea, an opportunity, or just a hello?

Build with me.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file diff --git a/work/enterprise.html b/work/enterprise.html index cc4ffe2..3233503 100644 --- a/work/enterprise.html +++ b/work/enterprise.html @@ -7,6 +7,7 @@ + @@ -27,12 +28,12 @@ - + - + - +

Private freelance work

Enterprise applications.

I have developed internal enterprise applications and business automation tools for private clients, supporting everyday operations, collaboration and documentation.

Engagement
Private freelance
Focus
Business automation
Availability
Confidential

Finance workflows

Enterprise Invoice Management System

An internal application for managing company invoices, records and related business workflows.

Team workflows

Team Collaboration & Workflow Platform

An internal collaboration application designed to improve communication, task coordination and team productivity.

Document automation

Proposal Document Generator

An automated system for generating standardized business proposals and formatted project documentation.

Audit workflows

Audit Management & Compliance System

Internal software supporting audit and compliance workflows, documentation, tracking and reporting, including contributions to audit and project environments associated with NTPC, Bluestar and other enterprise clients.

Explore public work

An idea, an opportunity, or just a hello?

Build with me.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file diff --git a/work/markdown-viewer.html b/work/markdown-viewer.html index 158c17c..35c600b 100644 --- a/work/markdown-viewer.html +++ b/work/markdown-viewer.html @@ -7,6 +7,7 @@ + @@ -27,12 +28,12 @@ - + - + - +

Developer tools · Open source

Markdown Viewer

A local-first Markdown workspace, from the first draft to the shared document.

Project
Actively maintained
Period
2024–2026
Built with
JavaScript, IndexedDB, Cloudflare
Markdown Viewer workspace showing its document explorer, editor and rendered preview.

Overview

A local-first Markdown workspace, from the first draft to the shared document.

Created and maintain the editor, its document workspace, rendering workflows and web/desktop delivery.

The problem

Technical writing needs more than a preview: documents, diagrams, review and sharing should work together.

My contribution

Created and maintain the editor, its document workspace, rendering workflows and web/desktop delivery.

JavaScriptIndexedDBCloudflareNeutralinojsPlaywright

Inside the build

  • Folders, tabs, IndexedDB autosave and encrypted workspace storage.
  • Rich rendering, anchored review comments and optional Live Share.

Project notes

Actively maintained

Markdown Viewer is part of my work in developer tools · open source. The links above provide the available project and implementation details.

Next projectSei-Sangeet-Bangla

An idea, an opportunity, or just a hello?

Build with me.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file diff --git a/work/medical-chatbot.html b/work/medical-chatbot.html index d568332..c875f14 100644 --- a/work/medical-chatbot.html +++ b/work/medical-chatbot.html @@ -7,6 +7,7 @@ + @@ -27,12 +28,12 @@ - + - + - +

AI · Retrieval experiment

Llama 2 GGML Medical Chatbot

An experiment in answering questions from medical reference documents with a quantized language model.

Project
Research prototype
Period
2023–2024
Built with
Python, Llama 2, LangChain
Llama 2 medical document retrieval prototype interface

Overview

An experiment in answering questions from medical reference documents with a quantized language model.

Built the retrieval workflow, model integration and chatbot interface using Python and LangChain.

The problem

Explore document-grounded question answering using local model inference and retrieved source passages.

My contribution

Built the retrieval workflow, model integration and chatbot interface using Python and LangChain.

PythonLlama 2LangChainFAISSChainlit

Inside the build

  • Retrieve document passages using sentence-transformer embeddings and FAISS.
  • Pass retrieved context to a pretrained Llama 2 model and return source documents with the answer.

Project notes

Research prototype

This is a document-retrieval experiment, not a clinical system or a substitute for professional medical advice. It uses a pretrained model; no model-training or clinical-accuracy claim is made.

Next projectBody Language Detection

An idea, an opportunity, or just a hello?

Build with me.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file diff --git a/work/medichain.html b/work/medichain.html index c09a0e7..ea6de13 100644 --- a/work/medichain.html +++ b/work/medichain.html @@ -7,6 +7,7 @@ + @@ -27,12 +28,12 @@ - + - + - +

Full stack · Blockchain prototype

MediChain

Making a medicine's journey visible across a pharmaceutical supply chain.

Project
Team prototype
Period
2024–2025
Built with
JavaScript, Solidity, Ethereum
MediChain dashboard showing medicine stock and supply-chain records.

Overview

Making a medicine's journey visible across a pharmaceutical supply chain.

Led full-stack development in a four-person team, connecting the web interface with blockchain workflows.

The problem

Medicine registration, inventory and purchase history need a shared record across supply-chain roles.

My contribution

Led full-stack development in a four-person team, connecting the web interface with blockchain workflows.

JavaScriptSolidityEthereumWeb3.jsMetaMask

Inside the build

  • Track medicine batches, lifecycle states and purchase history.
  • Connect role-based web interfaces to Ethereum contracts.

Project notes

Team prototype

MediChain is part of my work in full stack · blockchain prototype. The links above provide the available project and implementation details.

Next projectAMS

An idea, an opportunity, or just a hello?

Build with me.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file diff --git a/work/news-scraper.html b/work/news-scraper.html index d6f0ff9..2f48755 100644 --- a/work/news-scraper.html +++ b/work/news-scraper.html @@ -7,6 +7,7 @@ + @@ -27,12 +28,12 @@ - + - + - +

Automation · Python

News Scraping Platform

A Django experiment for collecting news articles from multiple sources.

Project
Archived experiment
Period
2023–2024
Built with
Python, Django, Selenium
News Scraping Platform — replaceable project image
Project image placeholder

Overview

A Django experiment for collecting news articles from multiple sources.

Built scraping workflows with BeautifulSoup and Selenium, integrated into Django.

The problem

Repeated article collection can be automated into a consistent data workflow.

My contribution

Built scraping workflows with BeautifulSoup and Selenium, integrated into Django.

PythonDjangoSeleniumBeautifulSoupMySQL

Inside the build

  • Concurrent collection from multiple publications.
  • Export collected records to Excel or MySQL.

Project notes

Archived experiment

News Scraping Platform is part of my work in automation · python. The links above provide the available project and implementation details.

Next projectTaskFlow

An idea, an opportunity, or just a hello?

Build with me.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file diff --git a/work/notemarker.html b/work/notemarker.html index acfb2bd..c393417 100644 --- a/work/notemarker.html +++ b/work/notemarker.html @@ -7,6 +7,7 @@ + @@ -27,12 +28,12 @@ - + - + - +

Browser extension · Productivity

NoteMarker Extension

Keep the useful parts of the web, right where you found them.

Project
Released for Firefox & Chromium
Period
2024
Built with
JavaScript, WebExtensions, Browser Storage
NoteMarker highlights and sticky notes on a research paper webpage.

Overview

Keep the useful parts of the web, right where you found them.

Built and maintain an open-source extension for highlighting webpages and creating persistent notes.

The problem

Research gets fragmented when highlights and notes are separated from the pages that inspired them.

My contribution

Built and maintain an open-source extension for highlighting webpages and creating persistent notes.

JavaScriptWebExtensionsBrowser StorageHTML & CSS

Inside the build

  • Persistent five-color highlights and rich-text sticky notes.
  • Move, resize and manage annotations on the original webpage.

Project notes

Released for Firefox & Chromium

NoteMarker Extension is part of my work in browser extension · productivity. The links above provide the available project and implementation details.

Next projectSketchFlow

An idea, an opportunity, or just a hello?

Build with me.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file diff --git a/work/sei-sangeet-bangla.html b/work/sei-sangeet-bangla.html index 508690e..4d6532c 100644 --- a/work/sei-sangeet-bangla.html +++ b/work/sei-sangeet-bangla.html @@ -7,6 +7,7 @@ + @@ -27,12 +28,12 @@ - + - + - +

Web app · Music

Sei-Sangeet-Bangla

A Bengali music experience set inside an illustrated room, with a song list and a familiar player.

Project
Private source
Period
2026
Built with
Web development
Sei-Sangeet-Bangla music room with Bengali song list and player

Overview

A Bengali music experience set inside an illustrated room, with a song list and a familiar player.

Developed Sei-Sangeet-Bangla as a personal music project.

The problem

Create a dedicated web experience around Bengali music.

My contribution

Developed Sei-Sangeet-Bangla as a personal music project.

Web development

Inside the build

  • Browse the song list inside a nostalgic, illustrated Bengali room.
  • Use track, playback, seek and volume controls in the music player.

Project notes

Private source

The project is included in my portfolio, while its source repository remains private.

Next projectLlama 2 GGML Medical Chatbot

An idea, an opportunity, or just a hello?

Build with me.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file diff --git a/work/simon.html b/work/simon.html index 44a8421..6ca01b0 100644 --- a/work/simon.html +++ b/work/simon.html @@ -7,6 +7,7 @@ + @@ -27,12 +28,12 @@ - + - + - +

Web app · Memory game

Simon

A browser memory game built around color, sound and an increasingly long sequence to remember.

Project
Open-source game
Period
2024
Built with
JavaScript, HTML & CSS, localStorage
Simon — replaceable project image
Project image placeholder

Overview

A browser memory game built around color, sound and an increasingly long sequence to remember.

Built the game interface, randomized sequence logic, audio feedback and local high-score tracking in JavaScript.

The problem

Recreate the classic Simon memory challenge with immediate visual feedback and simple browser controls.

My contribution

Built the game interface, randomized sequence logic, audio feedback and local high-score tracking in JavaScript.

JavaScriptHTML & CSSlocalStorage

Inside the build

  • Follow flashing colors and repeat the growing sequence to advance through levels.
  • Hear feedback on button presses and mistakes, restart a round and keep the highest score in the current browser.

Project notes

Open-source game

The high score is saved locally in the current browser. The game is a personal implementation of the classic Simon memory challenge.

Next projectMarkdown Viewer

An idea, an opportunity, or just a hello?

Build with me.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file diff --git a/work/sketchflow.html b/work/sketchflow.html index e30151f..569d55f 100644 --- a/work/sketchflow.html +++ b/work/sketchflow.html @@ -7,6 +7,7 @@ + @@ -27,12 +28,12 @@ - + - + - +

Canvas · Creative tool

SketchFlow

A browser canvas for sketching ideas, diagrams and freehand drawings.

Project
Open-source experiment
Period
2024–2026
Built with
JavaScript, Canvas API, HTML & CSS
SketchFlow drawing canvas with a colorful circular sketch.

Overview

A browser canvas for sketching ideas, diagrams and freehand drawings.

Built the canvas interaction, shape tools and drawing controls in vanilla JavaScript.

The problem

Quick visual thinking benefits from a lightweight drawing workspace.

My contribution

Built the canvas interaction, shape tools and drawing controls in vanilla JavaScript.

JavaScriptCanvas APIHTML & CSS

Inside the build

  • Draw shapes, arrows, text and freehand strokes.
  • Adjust styling and zoom; export PNG or save JSON.

Project notes

Open-source experiment

SketchFlow is part of my work in canvas · creative tool. The links above provide the available project and implementation details.

Next projectLlama 2 GGML CSV Chatbot

An idea, an opportunity, or just a hello?

Build with me.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file diff --git a/work/taskflow.html b/work/taskflow.html index d540fce..c4e6f7c 100644 --- a/work/taskflow.html +++ b/work/taskflow.html @@ -7,6 +7,7 @@ + @@ -27,12 +28,12 @@ - + - + - +

Developer tools · Productivity

TaskFlow

A flexible task board for organizing work, one column and one card at a time.

Project
Open-source tool
Period
2024–2025
Built with
JavaScript, HTML & CSS, Bootstrap
TaskFlow task cards arranged in To Do, In Progress and Done columns

Overview

A flexible task board for organizing work, one column and one card at a time.

Built a browser-based task management interface with customizable columns and drag-and-drop task cards.

The problem

Keep tasks, priorities and supporting details together in a lightweight visual workflow.

My contribution

Built a browser-based task management interface with customizable columns and drag-and-drop task cards.

JavaScriptHTML & CSSBootstraplocalStorage

Inside the build

  • Organize tasks with due dates, priorities, assignees, tags, comments and checklists.
  • Search tasks, move cards between columns and save the board in browser localStorage.

Project notes

Open-source tool

The board is stored in the current browser. Assignee fields help organize work; they do not imply a hosted multi-user collaboration backend.

Next projectSimon

An idea, an opportunity, or just a hello?

Build with me.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file From 8a9e3dcb16b74886fd919899681628d61bb38f65 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Sun, 13 Sep 2026 17:25:39 +0530 Subject: [PATCH 16/22] fix(theme): preserve frosted glass on dark project folders --- 404.html | 2 +- about.html | 2 +- blog.html | 2 +- ...r-browser-your-desktop-and-a-single-url-488m.html | 2 +- ...nsive-guide-to-development-and-features-4gpf.html | 2 +- blog/do-you-know-llama--3c2d.html | 2 +- blog/epic-games-membership-on-github-3ml.html | 2 +- blog/introducing-markdown-viewer-v20-com.html | 2 +- blog/llama-2-ggml-medical-chatbot-5043.html | 2 +- blog/opencvthe-enchanted-palette-part-2-424k.html | 2 +- blog/opencvunleashing-the-magic-of-images-1kdb.html | 2 +- blog/portfolio-thisis-developer-56bf.html | 2 +- blog/record-and-process-audio-with-python-2a3b.html | 2 +- ...e-your-github-commits-with-verification-3hja.html | 2 +- docs/design-system.md | 2 +- index.html | 2 +- play-lab.html | 2 +- project.html | 2 +- scripts/build.mjs | 2 +- style.css | 12 ++++-------- work.html | 2 +- work/ams.html | 2 +- work/body-language.html | 2 +- work/csv-chatbot.html | 2 +- work/enterprise.html | 2 +- work/markdown-viewer.html | 2 +- work/medical-chatbot.html | 2 +- work/medichain.html | 2 +- work/news-scraper.html | 2 +- work/notemarker.html | 2 +- work/sei-sangeet-bangla.html | 2 +- work/simon.html | 2 +- work/sketchflow.html | 2 +- work/taskflow.html | 2 +- 34 files changed, 37 insertions(+), 41 deletions(-) diff --git a/404.html b/404.html index 6629143..6be10fe 100644 --- a/404.html +++ b/404.html @@ -28,7 +28,7 @@ - + diff --git a/about.html b/about.html index 88e465f..165f303 100644 --- a/about.html +++ b/about.html @@ -28,7 +28,7 @@ - + diff --git a/blog.html b/blog.html index 3ce2954..465c5bf 100644 --- a/blog.html +++ b/blog.html @@ -28,7 +28,7 @@ - + diff --git a/blog/a-markdown-editor-that-lives-in-your-browser-your-desktop-and-a-single-url-488m.html b/blog/a-markdown-editor-that-lives-in-your-browser-your-desktop-and-a-single-url-488m.html index 49b0ab4..01f4630 100644 --- a/blog/a-markdown-editor-that-lives-in-your-browser-your-desktop-and-a-single-url-488m.html +++ b/blog/a-markdown-editor-that-lives-in-your-browser-your-desktop-and-a-single-url-488m.html @@ -28,7 +28,7 @@ - + diff --git a/blog/building-a-markdown-viewer-web-application-a-comprehensive-guide-to-development-and-features-4gpf.html b/blog/building-a-markdown-viewer-web-application-a-comprehensive-guide-to-development-and-features-4gpf.html index a926ba6..6234ac7 100644 --- a/blog/building-a-markdown-viewer-web-application-a-comprehensive-guide-to-development-and-features-4gpf.html +++ b/blog/building-a-markdown-viewer-web-application-a-comprehensive-guide-to-development-and-features-4gpf.html @@ -28,7 +28,7 @@ - + diff --git a/blog/do-you-know-llama--3c2d.html b/blog/do-you-know-llama--3c2d.html index 77a0212..3b87a13 100644 --- a/blog/do-you-know-llama--3c2d.html +++ b/blog/do-you-know-llama--3c2d.html @@ -28,7 +28,7 @@ - + diff --git a/blog/epic-games-membership-on-github-3ml.html b/blog/epic-games-membership-on-github-3ml.html index 5fce08f..58582d1 100644 --- a/blog/epic-games-membership-on-github-3ml.html +++ b/blog/epic-games-membership-on-github-3ml.html @@ -28,7 +28,7 @@ - + diff --git a/blog/introducing-markdown-viewer-v20-com.html b/blog/introducing-markdown-viewer-v20-com.html index 38e3038..3650f56 100644 --- a/blog/introducing-markdown-viewer-v20-com.html +++ b/blog/introducing-markdown-viewer-v20-com.html @@ -28,7 +28,7 @@ - + diff --git a/blog/llama-2-ggml-medical-chatbot-5043.html b/blog/llama-2-ggml-medical-chatbot-5043.html index 863f886..afbdc55 100644 --- a/blog/llama-2-ggml-medical-chatbot-5043.html +++ b/blog/llama-2-ggml-medical-chatbot-5043.html @@ -28,7 +28,7 @@ - + diff --git a/blog/opencvthe-enchanted-palette-part-2-424k.html b/blog/opencvthe-enchanted-palette-part-2-424k.html index 3c007dd..a4dfcd3 100644 --- a/blog/opencvthe-enchanted-palette-part-2-424k.html +++ b/blog/opencvthe-enchanted-palette-part-2-424k.html @@ -28,7 +28,7 @@ - + diff --git a/blog/opencvunleashing-the-magic-of-images-1kdb.html b/blog/opencvunleashing-the-magic-of-images-1kdb.html index 5bcad63..15b6c49 100644 --- a/blog/opencvunleashing-the-magic-of-images-1kdb.html +++ b/blog/opencvunleashing-the-magic-of-images-1kdb.html @@ -28,7 +28,7 @@ - + diff --git a/blog/portfolio-thisis-developer-56bf.html b/blog/portfolio-thisis-developer-56bf.html index 0214952..182d48d 100644 --- a/blog/portfolio-thisis-developer-56bf.html +++ b/blog/portfolio-thisis-developer-56bf.html @@ -28,7 +28,7 @@ - + diff --git a/blog/record-and-process-audio-with-python-2a3b.html b/blog/record-and-process-audio-with-python-2a3b.html index e966253..e335921 100644 --- a/blog/record-and-process-audio-with-python-2a3b.html +++ b/blog/record-and-process-audio-with-python-2a3b.html @@ -28,7 +28,7 @@ - + diff --git a/blog/secure-your-github-commits-with-verification-3hja.html b/blog/secure-your-github-commits-with-verification-3hja.html index 39bb29b..79ad264 100644 --- a/blog/secure-your-github-commits-with-verification-3hja.html +++ b/blog/secure-your-github-commits-with-verification-3hja.html @@ -28,7 +28,7 @@ - + diff --git a/docs/design-system.md b/docs/design-system.md index 8573187..98c6a7a 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -19,7 +19,7 @@ The portfolio uses a warm dotted canvas, expressive serif type, translucent past The six folder tints are #b8f0d8, #d4c9f5, #b8cef5, #f5d4b8, #f2a65a and #c8e6c0. Folder labels use dark ink for readable contrast. Each folder has three fanned sheets, a frosted front starting at 32% height, and a 400:340 proportion. -Dark mode gives each folder a deep version of its original tint, lighter labels, dark sheet frames, and quieter glass highlights. Actual project screenshots retain their original colours. Shared Blog and private-work illustrations use matching dark backgrounds, sheets, accent colours, and subtle highlights; the same palettes also apply to home-page writing cards and article covers. Light and print artwork keep their original palettes. +Dark mode gives each folder a deep version of its original tint, lighter labels, dark sheet frames, and quieter glass highlights. Both themes share the translucent 50–75% front gradient, 22px backdrop blur, and 190% saturation so the artwork shows through the glass. Dark mode dims the blurred backdrop to keep light labels readable, with a fine rim and inset highlight. Actual project screenshots retain their original colours. Shared Blog and private-work illustrations use matching dark backgrounds, sheets, accent colours, and subtle highlights; the same palettes also apply to home-page writing cards and article covers. Light and print artwork keep their original palettes. Every generated page loads the small synchronous `theme.js` script in its head before stylesheets and content. It reads `portfolio-theme`, applies the root theme and browser toolbar colour, and defaults to light if storage is unavailable. The deferred navigation script adopts that theme without resetting it and refreshes saved preferences when a page returns from the browser's back/forward cache. The bootstrap is self-hosted to work with the existing Content Security Policy. Run `npm run build` and `npm run test:theme` to verify dark first frames while the main script is delayed, navigation, card palettes, and storage failure behaviour against the built site. diff --git a/index.html b/index.html index 30e5437..9cc0eb4 100644 --- a/index.html +++ b/index.html @@ -28,7 +28,7 @@ - + diff --git a/play-lab.html b/play-lab.html index 10d3f6d..12ca153 100644 --- a/play-lab.html +++ b/play-lab.html @@ -28,7 +28,7 @@ - + diff --git a/project.html b/project.html index 06573aa..7dbd600 100644 --- a/project.html +++ b/project.html @@ -28,7 +28,7 @@ - + diff --git a/scripts/build.mjs b/scripts/build.mjs index 4411d2d..8479f91 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -120,7 +120,7 @@ function metadata(options = {}) { - + ${options.page ? '' : ""} diff --git a/style.css b/style.css index ee1d635..cced292 100644 --- a/style.css +++ b/style.css @@ -670,16 +670,12 @@ input { border-color: var(--glass); } :root[data-theme="dark"] .folder-front { - background: linear-gradient( - 155deg, - color-mix(in srgb, var(--tint) 92%, transparent), - color-mix(in srgb, var(--tint) 97%, transparent) - ); - border-color: rgb(255 255 255 / 0.12); + /* Share the light theme's translucent gradient; dim only the backdrop. */ + border-color: rgb(255 255 255 / 0.2); box-shadow: - inset 0 1px 0 #ffffff0d, + inset 0 1px 0 #ffffff2e, 0 6px 18px #00000026; - backdrop-filter: blur(22px) saturate(120%); + backdrop-filter: blur(22px) saturate(190%) brightness(0.6); } :root[data-theme="dark"] .folder-tags span, :root[data-theme="dark"] .folder-arrow { diff --git a/work.html b/work.html index 06573aa..7dbd600 100644 --- a/work.html +++ b/work.html @@ -28,7 +28,7 @@ - + diff --git a/work/ams.html b/work/ams.html index 6af5750..e91f140 100644 --- a/work/ams.html +++ b/work/ams.html @@ -28,7 +28,7 @@ - + diff --git a/work/body-language.html b/work/body-language.html index 4c34a5e..782317e 100644 --- a/work/body-language.html +++ b/work/body-language.html @@ -28,7 +28,7 @@ - + diff --git a/work/csv-chatbot.html b/work/csv-chatbot.html index 0983ad0..648df7b 100644 --- a/work/csv-chatbot.html +++ b/work/csv-chatbot.html @@ -28,7 +28,7 @@ - + diff --git a/work/enterprise.html b/work/enterprise.html index 3233503..f563fb4 100644 --- a/work/enterprise.html +++ b/work/enterprise.html @@ -28,7 +28,7 @@ - + diff --git a/work/markdown-viewer.html b/work/markdown-viewer.html index 35c600b..1eba0e4 100644 --- a/work/markdown-viewer.html +++ b/work/markdown-viewer.html @@ -28,7 +28,7 @@ - + diff --git a/work/medical-chatbot.html b/work/medical-chatbot.html index c875f14..95a0f33 100644 --- a/work/medical-chatbot.html +++ b/work/medical-chatbot.html @@ -28,7 +28,7 @@ - + diff --git a/work/medichain.html b/work/medichain.html index ea6de13..5e732e2 100644 --- a/work/medichain.html +++ b/work/medichain.html @@ -28,7 +28,7 @@ - + diff --git a/work/news-scraper.html b/work/news-scraper.html index 2f48755..f6090e1 100644 --- a/work/news-scraper.html +++ b/work/news-scraper.html @@ -28,7 +28,7 @@ - + diff --git a/work/notemarker.html b/work/notemarker.html index c393417..130fa71 100644 --- a/work/notemarker.html +++ b/work/notemarker.html @@ -28,7 +28,7 @@ - + diff --git a/work/sei-sangeet-bangla.html b/work/sei-sangeet-bangla.html index 4d6532c..84df3f1 100644 --- a/work/sei-sangeet-bangla.html +++ b/work/sei-sangeet-bangla.html @@ -28,7 +28,7 @@ - + diff --git a/work/simon.html b/work/simon.html index 6ca01b0..ee678ef 100644 --- a/work/simon.html +++ b/work/simon.html @@ -28,7 +28,7 @@ - + diff --git a/work/sketchflow.html b/work/sketchflow.html index 569d55f..600395c 100644 --- a/work/sketchflow.html +++ b/work/sketchflow.html @@ -28,7 +28,7 @@ - + diff --git a/work/taskflow.html b/work/taskflow.html index c4e6f7c..6fb611f 100644 --- a/work/taskflow.html +++ b/work/taskflow.html @@ -28,7 +28,7 @@ - + From 41eadda4e3d93a735166a0ad477f0015b9937f61 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Sun, 13 Sep 2026 17:30:52 +0530 Subject: [PATCH 17/22] fix(theme): preserve pastel project colours in dark mode --- 404.html | 2 +- about.html | 2 +- blog.html | 2 +- ...er-your-desktop-and-a-single-url-488m.html | 2 +- ...uide-to-development-and-features-4gpf.html | 2 +- blog/do-you-know-llama--3c2d.html | 2 +- blog/epic-games-membership-on-github-3ml.html | 2 +- blog/introducing-markdown-viewer-v20-com.html | 2 +- blog/llama-2-ggml-medical-chatbot-5043.html | 2 +- ...encvthe-enchanted-palette-part-2-424k.html | 2 +- ...cvunleashing-the-magic-of-images-1kdb.html | 2 +- blog/portfolio-thisis-developer-56bf.html | 2 +- ...rd-and-process-audio-with-python-2a3b.html | 2 +- ...github-commits-with-verification-3hja.html | 2 +- docs/design-system.md | 2 +- index.html | 2 +- play-lab.html | 2 +- project.html | 2 +- scripts/build.mjs | 2 +- scripts/theme-checks.mjs | 18 +++++--- style.css | 43 ------------------- work.html | 2 +- work/ams.html | 2 +- work/body-language.html | 2 +- work/csv-chatbot.html | 2 +- work/enterprise.html | 2 +- work/markdown-viewer.html | 2 +- work/medical-chatbot.html | 2 +- work/medichain.html | 2 +- work/news-scraper.html | 2 +- work/notemarker.html | 2 +- work/sei-sangeet-bangla.html | 2 +- work/simon.html | 2 +- work/sketchflow.html | 2 +- work/taskflow.html | 2 +- 35 files changed, 44 insertions(+), 83 deletions(-) diff --git a/404.html b/404.html index 6be10fe..04c1121 100644 --- a/404.html +++ b/404.html @@ -28,7 +28,7 @@ - + diff --git a/about.html b/about.html index 165f303..bc40fb3 100644 --- a/about.html +++ b/about.html @@ -28,7 +28,7 @@ - + diff --git a/blog.html b/blog.html index 465c5bf..8b9b1ef 100644 --- a/blog.html +++ b/blog.html @@ -28,7 +28,7 @@ - + diff --git a/blog/a-markdown-editor-that-lives-in-your-browser-your-desktop-and-a-single-url-488m.html b/blog/a-markdown-editor-that-lives-in-your-browser-your-desktop-and-a-single-url-488m.html index 01f4630..572d482 100644 --- a/blog/a-markdown-editor-that-lives-in-your-browser-your-desktop-and-a-single-url-488m.html +++ b/blog/a-markdown-editor-that-lives-in-your-browser-your-desktop-and-a-single-url-488m.html @@ -28,7 +28,7 @@ - + diff --git a/blog/building-a-markdown-viewer-web-application-a-comprehensive-guide-to-development-and-features-4gpf.html b/blog/building-a-markdown-viewer-web-application-a-comprehensive-guide-to-development-and-features-4gpf.html index 6234ac7..774a064 100644 --- a/blog/building-a-markdown-viewer-web-application-a-comprehensive-guide-to-development-and-features-4gpf.html +++ b/blog/building-a-markdown-viewer-web-application-a-comprehensive-guide-to-development-and-features-4gpf.html @@ -28,7 +28,7 @@ - + diff --git a/blog/do-you-know-llama--3c2d.html b/blog/do-you-know-llama--3c2d.html index 3b87a13..13c3af4 100644 --- a/blog/do-you-know-llama--3c2d.html +++ b/blog/do-you-know-llama--3c2d.html @@ -28,7 +28,7 @@ - + diff --git a/blog/epic-games-membership-on-github-3ml.html b/blog/epic-games-membership-on-github-3ml.html index 58582d1..97b62ca 100644 --- a/blog/epic-games-membership-on-github-3ml.html +++ b/blog/epic-games-membership-on-github-3ml.html @@ -28,7 +28,7 @@ - + diff --git a/blog/introducing-markdown-viewer-v20-com.html b/blog/introducing-markdown-viewer-v20-com.html index 3650f56..e5702f3 100644 --- a/blog/introducing-markdown-viewer-v20-com.html +++ b/blog/introducing-markdown-viewer-v20-com.html @@ -28,7 +28,7 @@ - + diff --git a/blog/llama-2-ggml-medical-chatbot-5043.html b/blog/llama-2-ggml-medical-chatbot-5043.html index afbdc55..df45a47 100644 --- a/blog/llama-2-ggml-medical-chatbot-5043.html +++ b/blog/llama-2-ggml-medical-chatbot-5043.html @@ -28,7 +28,7 @@ - + diff --git a/blog/opencvthe-enchanted-palette-part-2-424k.html b/blog/opencvthe-enchanted-palette-part-2-424k.html index a4dfcd3..1cff515 100644 --- a/blog/opencvthe-enchanted-palette-part-2-424k.html +++ b/blog/opencvthe-enchanted-palette-part-2-424k.html @@ -28,7 +28,7 @@ - + diff --git a/blog/opencvunleashing-the-magic-of-images-1kdb.html b/blog/opencvunleashing-the-magic-of-images-1kdb.html index 15b6c49..1ab3d66 100644 --- a/blog/opencvunleashing-the-magic-of-images-1kdb.html +++ b/blog/opencvunleashing-the-magic-of-images-1kdb.html @@ -28,7 +28,7 @@ - + diff --git a/blog/portfolio-thisis-developer-56bf.html b/blog/portfolio-thisis-developer-56bf.html index 182d48d..d574fb5 100644 --- a/blog/portfolio-thisis-developer-56bf.html +++ b/blog/portfolio-thisis-developer-56bf.html @@ -28,7 +28,7 @@ - + diff --git a/blog/record-and-process-audio-with-python-2a3b.html b/blog/record-and-process-audio-with-python-2a3b.html index e335921..34e093e 100644 --- a/blog/record-and-process-audio-with-python-2a3b.html +++ b/blog/record-and-process-audio-with-python-2a3b.html @@ -28,7 +28,7 @@ - + diff --git a/blog/secure-your-github-commits-with-verification-3hja.html b/blog/secure-your-github-commits-with-verification-3hja.html index 79ad264..53ad44e 100644 --- a/blog/secure-your-github-commits-with-verification-3hja.html +++ b/blog/secure-your-github-commits-with-verification-3hja.html @@ -28,7 +28,7 @@ - + diff --git a/docs/design-system.md b/docs/design-system.md index 98c6a7a..212ed58 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -19,7 +19,7 @@ The portfolio uses a warm dotted canvas, expressive serif type, translucent past The six folder tints are #b8f0d8, #d4c9f5, #b8cef5, #f5d4b8, #f2a65a and #c8e6c0. Folder labels use dark ink for readable contrast. Each folder has three fanned sheets, a frosted front starting at 32% height, and a 400:340 proportion. -Dark mode gives each folder a deep version of its original tint, lighter labels, dark sheet frames, and quieter glass highlights. Both themes share the translucent 50–75% front gradient, 22px backdrop blur, and 190% saturation so the artwork shows through the glass. Dark mode dims the blurred backdrop to keep light labels readable, with a fine rim and inset highlight. Actual project screenshots retain their original colours. Shared Blog and private-work illustrations use matching dark backgrounds, sheets, accent colours, and subtle highlights; the same palettes also apply to home-page writing cards and article covers. Light and print artwork keep their original palettes. +All project folders on Home and Work keep the same mint, lavender, blue, peach, orange, and green colours in both themes. Their dark labels, white sheet frames, glass highlights, translucent 50–75% front gradient, 22px backdrop blur, and 190% saturation also stay consistent, so the artwork shows through the pastel glass against the dark page. Actual project screenshots retain their original colours. Shared Blog and private-work illustrations use matching dark backgrounds, sheets, accent colours, and subtle highlights; the same palettes also apply to home-page writing cards and article covers. Light and print artwork keep their original palettes. Every generated page loads the small synchronous `theme.js` script in its head before stylesheets and content. It reads `portfolio-theme`, applies the root theme and browser toolbar colour, and defaults to light if storage is unavailable. The deferred navigation script adopts that theme without resetting it and refreshes saved preferences when a page returns from the browser's back/forward cache. The bootstrap is self-hosted to work with the existing Content Security Policy. Run `npm run build` and `npm run test:theme` to verify dark first frames while the main script is delayed, navigation, card palettes, and storage failure behaviour against the built site. diff --git a/index.html b/index.html index 9cc0eb4..53ccef2 100644 --- a/index.html +++ b/index.html @@ -28,7 +28,7 @@ - + diff --git a/play-lab.html b/play-lab.html index 12ca153..ef4cb3e 100644 --- a/play-lab.html +++ b/play-lab.html @@ -28,7 +28,7 @@ - + diff --git a/project.html b/project.html index 7dbd600..81c634f 100644 --- a/project.html +++ b/project.html @@ -28,7 +28,7 @@ - + diff --git a/scripts/build.mjs b/scripts/build.mjs index 8479f91..013b1eb 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -120,7 +120,7 @@ function metadata(options = {}) { - + ${options.page ? '' : ""} diff --git a/scripts/theme-checks.mjs b/scripts/theme-checks.mjs index 544ce13..bd70ba9 100644 --- a/scripts/theme-checks.mjs +++ b/scripts/theme-checks.mjs @@ -91,6 +91,7 @@ export async function themeChecks(browser, url) { await page.setViewportSize({ width, height: 1000 }); for (const route of ["/blog", "/work", "/", article]) { await page.goto(`${url}${route}`, { waitUntil: "networkidle" }); + let darkFolders; for (const dark of [true, false]) { if (await page.locator(".theme-toggle").getAttribute("aria-pressed") !== String(dark)) await page.locator(".theme-toggle").dispatchEvent("click"); @@ -114,18 +115,21 @@ export async function themeChecks(browser, url) { } } } - if (dark) { - const folders = await page.locator(".folder").evaluateAll((elements) => elements.map((el) => ({ + const folders = await page.locator(".folder").evaluateAll((elements) => elements.map((el) => ({ background: getComputedStyle(el.querySelector(".folder-back")).backgroundColor, color: getComputedStyle(el.querySelector(".folder-front")).color, + glass: getComputedStyle(el.querySelector(".folder-front")).backgroundImage, + blur: getComputedStyle(el.querySelector(".folder-front")).backdropFilter, + rim: getComputedStyle(el.querySelector(".folder-front")).borderColor, + sheet: getComputedStyle(el.querySelector(".folder-sheet")).borderColor, filter: getComputedStyle(el.querySelector("img")).filter, }))); - for (const folder of folders) { - assert(luminance(folder.background) < 0.1, "Project folders use dark tints"); - assert(contrast(folder.color, folder.background) >= 4.5, "Folder text remains readable"); - assert.equal(folder.filter, "none", "Original project screenshots keep their colours"); - } + for (const folder of folders) { + assert(contrast(folder.color, folder.background) >= 4.5, "Folder text remains readable"); + assert.equal(folder.filter, "none", "Original project screenshots keep their colours"); } + if (dark) darkFolders = folders; + else assert.deepEqual(folders, darkFolders, `${route}: all project folders keep their colours and glass in both themes`); assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), `${route} at ${width}: no horizontal overflow`); } diff --git a/style.css b/style.css index cced292..97eaabc 100644 --- a/style.css +++ b/style.css @@ -640,49 +640,6 @@ input { width: 15px; height: 15px; } -@media screen { - :root[data-theme="dark"] .folder { - --tint: #243f36; - --folder-ink: #c4eddb; - } - :root[data-theme="dark"] .folder-2 { - --tint: #37304e; - --folder-ink: #e1d6fa; - } - :root[data-theme="dark"] .folder-3 { - --tint: #293b57; - --folder-ink: #d0e0fa; - } - :root[data-theme="dark"] .folder-4 { - --tint: #493528; - --folder-ink: #f5ddc7; - } - :root[data-theme="dark"] .folder-5 { - --tint: #4b3422; - --folder-ink: #f9d6af; - } - :root[data-theme="dark"] .folder-6 { - --tint: #2f402c; - --folder-ink: #d7edcd; - } - :root[data-theme="dark"] .folder-sheet { - background: var(--glass); - border-color: var(--glass); - } - :root[data-theme="dark"] .folder-front { - /* Share the light theme's translucent gradient; dim only the backdrop. */ - border-color: rgb(255 255 255 / 0.2); - box-shadow: - inset 0 1px 0 #ffffff2e, - 0 6px 18px #00000026; - backdrop-filter: blur(22px) saturate(190%) brightness(0.6); - } - :root[data-theme="dark"] .folder-tags span, - :root[data-theme="dark"] .folder-arrow { - background: rgb(255 255 255 / 0.07); - border-color: rgb(255 255 255 / 0.12); - } -} .folder:hover .sheet-1, .folder:focus-visible .sheet-1 { transform: translate(-18%, -20%) rotate(-8deg); diff --git a/work.html b/work.html index 7dbd600..81c634f 100644 --- a/work.html +++ b/work.html @@ -28,7 +28,7 @@ - + diff --git a/work/ams.html b/work/ams.html index e91f140..c1d2c0b 100644 --- a/work/ams.html +++ b/work/ams.html @@ -28,7 +28,7 @@ - + diff --git a/work/body-language.html b/work/body-language.html index 782317e..afb32e7 100644 --- a/work/body-language.html +++ b/work/body-language.html @@ -28,7 +28,7 @@ - + diff --git a/work/csv-chatbot.html b/work/csv-chatbot.html index 648df7b..b9d0331 100644 --- a/work/csv-chatbot.html +++ b/work/csv-chatbot.html @@ -28,7 +28,7 @@ - + diff --git a/work/enterprise.html b/work/enterprise.html index f563fb4..64a721a 100644 --- a/work/enterprise.html +++ b/work/enterprise.html @@ -28,7 +28,7 @@ - + diff --git a/work/markdown-viewer.html b/work/markdown-viewer.html index 1eba0e4..ebf3e57 100644 --- a/work/markdown-viewer.html +++ b/work/markdown-viewer.html @@ -28,7 +28,7 @@ - + diff --git a/work/medical-chatbot.html b/work/medical-chatbot.html index 95a0f33..75dc0d2 100644 --- a/work/medical-chatbot.html +++ b/work/medical-chatbot.html @@ -28,7 +28,7 @@ - + diff --git a/work/medichain.html b/work/medichain.html index 5e732e2..5cacdcd 100644 --- a/work/medichain.html +++ b/work/medichain.html @@ -28,7 +28,7 @@ - + diff --git a/work/news-scraper.html b/work/news-scraper.html index f6090e1..2a1c6b1 100644 --- a/work/news-scraper.html +++ b/work/news-scraper.html @@ -28,7 +28,7 @@ - + diff --git a/work/notemarker.html b/work/notemarker.html index 130fa71..aa50c83 100644 --- a/work/notemarker.html +++ b/work/notemarker.html @@ -28,7 +28,7 @@ - + diff --git a/work/sei-sangeet-bangla.html b/work/sei-sangeet-bangla.html index 84df3f1..d0e9efd 100644 --- a/work/sei-sangeet-bangla.html +++ b/work/sei-sangeet-bangla.html @@ -28,7 +28,7 @@ - + diff --git a/work/simon.html b/work/simon.html index ee678ef..e5aeab2 100644 --- a/work/simon.html +++ b/work/simon.html @@ -28,7 +28,7 @@ - + diff --git a/work/sketchflow.html b/work/sketchflow.html index 600395c..302fc2d 100644 --- a/work/sketchflow.html +++ b/work/sketchflow.html @@ -28,7 +28,7 @@ - + diff --git a/work/taskflow.html b/work/taskflow.html index 6fb611f..6662fac 100644 --- a/work/taskflow.html +++ b/work/taskflow.html @@ -28,7 +28,7 @@ - + From 9105db7a55f1a5cca0c56f9365e2230b7e36bb71 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Mon, 14 Sep 2026 00:22:51 +0530 Subject: [PATCH 18/22] feat(wallet): add compact mobile card deck without scrolling --- 404.html | 4 +- about.html | 4 +- blog.html | 4 +- ...er-your-desktop-and-a-single-url-488m.html | 4 +- ...uide-to-development-and-features-4gpf.html | 4 +- blog/do-you-know-llama--3c2d.html | 4 +- blog/epic-games-membership-on-github-3ml.html | 4 +- blog/introducing-markdown-viewer-v20-com.html | 4 +- blog/llama-2-ggml-medical-chatbot-5043.html | 4 +- ...encvthe-enchanted-palette-part-2-424k.html | 4 +- ...cvunleashing-the-magic-of-images-1kdb.html | 4 +- blog/portfolio-thisis-developer-56bf.html | 4 +- ...rd-and-process-audio-with-python-2a3b.html | 4 +- ...github-commits-with-verification-3hja.html | 4 +- docs/design-system.md | 4 +- index.html | 15 +- play-lab.html | 4 +- project.html | 4 +- script.js | 29 +++- scripts/build.mjs | 4 +- scripts/dot-checks.mjs | 6 +- scripts/home-sections.mjs | 11 +- scripts/verify.mjs | 2 + scripts/wallet-checks.mjs | 94 +++++++++++ style.css | 159 ++++++++++++++++-- work.html | 4 +- work/ams.html | 4 +- work/body-language.html | 4 +- work/csv-chatbot.html | 4 +- work/enterprise.html | 4 +- work/markdown-viewer.html | 4 +- work/medical-chatbot.html | 4 +- work/medichain.html | 4 +- work/news-scraper.html | 4 +- work/notemarker.html | 4 +- work/sei-sangeet-bangla.html | 4 +- work/simon.html | 4 +- work/sketchflow.html | 4 +- work/taskflow.html | 4 +- 39 files changed, 346 insertions(+), 98 deletions(-) create mode 100644 scripts/wallet-checks.mjs diff --git a/404.html b/404.html index 04c1121..f4ded89 100644 --- a/404.html +++ b/404.html @@ -28,12 +28,12 @@ - + - +

404

A small detour.

This page took a wrong turn.
Jump a few bugs, then find your way back.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file diff --git a/about.html b/about.html index bc40fb3..e5467a5 100644 --- a/about.html +++ b/about.html @@ -28,12 +28,12 @@ - + - +

Hey, I’m Baivab.

I build useful software, follow my curiosity,
and keep learning along the way.

Hover or select a photo to bring it forward. Use the arrow keys to explore, and Escape to put it back.

I like turning everyday friction into useful software. A better place to write Markdown. A way to trace a medicine’s journey. A test that catches the next broken workflow.

I’m Baivab, a software developer building open-source tools, thoughtful web experiences, and the tests that keep them reliable.

My work connects Java and JavaScript development with a growing practice in test automation. I care about the whole journey: how something works, how it feels to use, and what happens when it breaks.

CURRENTLY

West Bengal, India

Building tools. Learning in public.

THINGS I ENJOY WORKING WITH

Open sourceJavaScriptJavaTest automationBrowser toolsComputer scienceDeveloper experienceCreative coding
Creative codingDeveloper experienceComputer scienceBrowser toolsTest automationJavaJavaScriptOpen source

SELECTED TOOLS

JavaScriptPythonJavaSeleniumTensorFlowOpenCVDockerJenkinsGitCloudflareVercelFigma

What I enjoy working on

I’m drawn to the small problems that keep getting in the way: a document that needs a better workspace, information that needs to move between people, or a workflow that needs reliable tests.

That has taken me from Markdown Viewer and NoteMarker to MediChain, academic applications and private enterprise tools.

My process

I start with the workflow: what someone needs to do, where the friction is, and what a useful first version would look like. Then I build, test and refine the details.

My Java and Selenium training has made testing part of that process. I want the interface and the behavior behind it to make sense together.

Learning in public

My public repositories and blog are a record of that learning. There are polished tools, team prototypes and earlier experiments. Each has something that led to the next one.

Read my notes

Experience & learning

2024 — ongoing

Building in the open

Independent projects · Open source

Developing Markdown Viewer and other tools, working through product details from responsive interfaces to document rendering and collaboration.

Explore the source

Training completed

Java & Selenium QA automation

Hands-on SDET training

UI and API testing with Selenium WebDriver, TestNG, JUnit, Cucumber, Postman, and Jenkins. Practiced Page Object Model and repeatable test workflows.

Oct 2021 — May 2025

B.Tech, Computer Science & Engineering

JIS College of Engineering

Graduated with a CGPA of 9.15/10. Built a foundation in object-oriented programming, data structures, algorithms, databases, and the software development lifecycle.

PRIVATE FREELANCE WORK

Enterprise applications & automation

I have developed internal enterprise applications and business automation tools for private clients, supporting everyday operations, collaboration and documentation.

Private work overview

GitHub FoundationsNov 2024

Java Programming · UdemyMay 2025

Data Structures & Algorithms · UdemySep 2024

An idea, an opportunity, or just a hello?

Build with me.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file diff --git a/blog.html b/blog.html index 8b9b1ef..2868c77 100644 --- a/blog.html +++ b/blog.html @@ -28,12 +28,12 @@ - + - +

ENGINEERING & IDEAS

Built with care.
Shared in detail.

Inside the software I build: product decisions, practical guides and lessons from working with code.

11 posts

Markdown Viewer: one workspace across browser and desktop

A closer look at document tabs, desktop delivery, GitHub imports and sharing documents through a URL.

Baivab Sarkar

Inside Markdown Viewer 2.0

Extending a live-preview editor with diagrams, mathematical notation, syntax highlighting and a more complete writing interface.

Baivab Sarkar

Managing public membership in large GitHub organizations

A practical guide to changing organization membership visibility when the member directory is difficult to navigate.

Baivab Sarkar

Signing Git commits with GPG

Generate a GPG key, configure Git signing and verify signatures in your commit history.

Baivab Sarkar

Building a Markdown viewer for the web

The foundations of a browser-based Markdown editor: document input, rendered output and a practical setup workflow.

Baivab Sarkar

Question answering over CSV data with Llama 2

An experimental retrieval workflow that combines CSV documents, a pretrained language model and a chat interface.

Baivab Sarkar

Building a document-retrieval prototype with Llama 2

A Python experiment connecting reference passages to a pretrained language model through a conversational interface.

Baivab Sarkar

Image processing with OpenCV: filters, contours and histograms

Notebook examples covering resizing, morphology, filtering, thresholding and histogram analysis.

Baivab Sarkar

Getting started with images in OpenCV

A compact notebook-based introduction to loading and working with images using Python and OpenCV.

Baivab Sarkar

Recording and analysing audio with Python

Python examples for recording audio, loading files, plotting waveforms and generating spectrograms.

Baivab Sarkar

Connecting a portfolio contact form to Google Sheets

A historical implementation using browser JavaScript and Google Apps Script to store contact-form submissions.

Baivab Sarkar

An idea, an opportunity, or just a hello?

Build with me.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file diff --git a/blog/a-markdown-editor-that-lives-in-your-browser-your-desktop-and-a-single-url-488m.html b/blog/a-markdown-editor-that-lives-in-your-browser-your-desktop-and-a-single-url-488m.html index 572d482..7327d4f 100644 --- a/blog/a-markdown-editor-that-lives-in-your-browser-your-desktop-and-a-single-url-488m.html +++ b/blog/a-markdown-editor-that-lives-in-your-browser-your-desktop-and-a-single-url-488m.html @@ -28,13 +28,13 @@ - + - +

Projects

Markdown Viewer: one workspace across browser and desktop

A closer look at document tabs, desktop delivery, GitHub imports and sharing documents through a URL.

The latest Markdown Viewer rebuild brings document management, rendering and sharing into one workspace. This article explains the changes to multi-tab sessions, desktop delivery, GitHub imports and URL-based sharing, alongside the decisions behind them.

diff --git a/blog/building-a-markdown-viewer-web-application-a-comprehensive-guide-to-development-and-features-4gpf.html b/blog/building-a-markdown-viewer-web-application-a-comprehensive-guide-to-development-and-features-4gpf.html index 774a064..c3eecc1 100644 --- a/blog/building-a-markdown-viewer-web-application-a-comprehensive-guide-to-development-and-features-4gpf.html +++ b/blog/building-a-markdown-viewer-web-application-a-comprehensive-guide-to-development-and-features-4gpf.html @@ -28,13 +28,13 @@ - + - +

Projects

Building a Markdown viewer for the web

The foundations of a browser-based Markdown editor: document input, rendered output and a practical setup workflow.

A Markdown viewer connects a plain-text writing surface to rendered output. This guide introduces the format, walks through the features of the original web application and shows how to run the project locally.

diff --git a/blog/do-you-know-llama--3c2d.html b/blog/do-you-know-llama--3c2d.html index 13c3af4..4062a9f 100644 --- a/blog/do-you-know-llama--3c2d.html +++ b/blog/do-you-know-llama--3c2d.html @@ -28,13 +28,13 @@ - + - +

AI & data

Question answering over CSV data with Llama 2

An experimental retrieval workflow that combines CSV documents, a pretrained language model and a chat interface.

This project explores question answering over CSV data using a pretrained, quantized Llama 2 model. Retrieval supplies relevant document context; the project does not train or fine-tune the model on the uploaded data.

Llama2 CSV ChatBot

diff --git a/blog/epic-games-membership-on-github-3ml.html b/blog/epic-games-membership-on-github-3ml.html index 97b62ca..d9d16ac 100644 --- a/blog/epic-games-membership-on-github-3ml.html +++ b/blog/epic-games-membership-on-github-3ml.html @@ -28,13 +28,13 @@ - + - +

Git & tooling

Managing public membership in large GitHub organizations

A practical guide to changing organization membership visibility when the member directory is difficult to navigate.

Organization membership visibility determines whether an organization appears on your public GitHub profile. This guide documents a workflow for changing that setting in a large organization, using Epic Games as the example.

GitHub Fun Gif

diff --git a/blog/introducing-markdown-viewer-v20-com.html b/blog/introducing-markdown-viewer-v20-com.html index e5702f3..9368f57 100644 --- a/blog/introducing-markdown-viewer-v20-com.html +++ b/blog/introducing-markdown-viewer-v20-com.html @@ -28,13 +28,13 @@ - + - +

Projects

Inside Markdown Viewer 2.0

Extending a live-preview editor with diagrams, mathematical notation, syntax highlighting and a more complete writing interface.

Markdown Viewer 2.0 expands the original split-pane editor with diagrams, mathematical notation, syntax highlighting and an updated interface. This release overview covers the features added to support a broader technical-writing workflow.

diff --git a/blog/llama-2-ggml-medical-chatbot-5043.html b/blog/llama-2-ggml-medical-chatbot-5043.html index df45a47..13fa4d5 100644 --- a/blog/llama-2-ggml-medical-chatbot-5043.html +++ b/blog/llama-2-ggml-medical-chatbot-5043.html @@ -28,13 +28,13 @@ - + - +

AI & data

Building a document-retrieval prototype with Llama 2

A Python experiment connecting reference passages to a pretrained language model through a conversational interface.

This prototype retrieves passages from a medical reference document and supplies them to a pretrained, quantized Llama 2 model. It is an engineering experiment in retrieval and interface design. It has not been clinically validated and is not intended for medical decision-making.

diff --git a/blog/opencvthe-enchanted-palette-part-2-424k.html b/blog/opencvthe-enchanted-palette-part-2-424k.html index 1cff515..beef05c 100644 --- a/blog/opencvthe-enchanted-palette-part-2-424k.html +++ b/blog/opencvthe-enchanted-palette-part-2-424k.html @@ -28,13 +28,13 @@ - + - +

AI & data

Image processing with OpenCV: filters, contours and histograms

Notebook examples covering resizing, morphology, filtering, thresholding and histogram analysis.

This collection extends the basic image-loading workflow with common OpenCV processing operations. The examples cover resizing, morphology, contours, filtering, thresholding and histogram analysis, with links to the corresponding notebook.

Embark on a Visual Journey: Explore the OpenCV Chronicles in my GitHub Repository! #OpenCVMagic #ImageAlchemy #CodeCanvas
Source GitHub: ThisIs-Developer/Python/OpenCV/Image Processing
diff --git a/blog/opencvunleashing-the-magic-of-images-1kdb.html b/blog/opencvunleashing-the-magic-of-images-1kdb.html index 1ab3d66..51091f1 100644 --- a/blog/opencvunleashing-the-magic-of-images-1kdb.html +++ b/blog/opencvunleashing-the-magic-of-images-1kdb.html @@ -28,13 +28,13 @@ - + - +

AI & data

Getting started with images in OpenCV

A compact notebook-based introduction to loading and working with images using Python and OpenCV.

This notebook collection introduces working with images in Python using OpenCV. Install the package, open the linked notebook and follow the accompanying examples to explore the image-processing workflow.

diff --git a/blog/portfolio-thisis-developer-56bf.html b/blog/portfolio-thisis-developer-56bf.html index d574fb5..668b630 100644 --- a/blog/portfolio-thisis-developer-56bf.html +++ b/blog/portfolio-thisis-developer-56bf.html @@ -28,13 +28,13 @@ - + - +

Notes

Connecting a portfolio contact form to Google Sheets

A historical implementation using browser JavaScript and Google Apps Script to store contact-form submissions.

An earlier version of my portfolio used browser JavaScript and Google Apps Script to send contact-form submissions to a spreadsheet. This article records that implementation and its interface states; it describes the older project, rather than the contact flow on this website.

Website isLive:baivabsarkar-old.vercel.app

diff --git a/blog/record-and-process-audio-with-python-2a3b.html b/blog/record-and-process-audio-with-python-2a3b.html index 34e093e..6a2431c 100644 --- a/blog/record-and-process-audio-with-python-2a3b.html +++ b/blog/record-and-process-audio-with-python-2a3b.html @@ -28,13 +28,13 @@ - + - +

AI & data

Recording and analysing audio with Python

Python examples for recording audio, loading files, plotting waveforms and generating spectrograms.

These examples bring together audio recording, playback and analysis in Python. The code covers loading audio, examining sample data, plotting waveforms and generating spectrograms with Librosa and Matplotlib.

diff --git a/blog/secure-your-github-commits-with-verification-3hja.html b/blog/secure-your-github-commits-with-verification-3hja.html index 53ad44e..4235113 100644 --- a/blog/secure-your-github-commits-with-verification-3hja.html +++ b/blog/secure-your-github-commits-with-verification-3hja.html @@ -28,13 +28,13 @@ - + - +

Git & tooling

Signing Git commits with GPG

Generate a GPG key, configure Git signing and verify signatures in your commit history.

Signed commits attach a cryptographic signature to a Git commit. This walkthrough covers generating a GPG key, adding its public component to GitHub, configuring Git and checking the resulting signatures.

How to Set Up a GPG Key for Signed Git Commits: A Step-by-Step Guide

diff --git a/docs/design-system.md b/docs/design-system.md index 212ed58..9b0a669 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -60,8 +60,8 @@ Each binary's WOFF2 signature was verified. The SIL Open Font License texts (wit The generated SVGs in assets/about and assets/placeholders are explicitly labeled placeholders. To replace personal photos, update the photo strip in scripts/site-pages.mjs and publish the matching image asset. To replace a project image, set its image, imageAlt, imageWidth and imageHeight in the project JSON and supply the responsive variants used by the folder renderer. Keep real screenshots separate from private enterprise summaries. -Capabilities open the first row when visible, fill a bright green progress ring over four seconds, then cycle forward. Each detail is one concise paragraph with no hover state. Selecting any row closes the previous one and restarts the timer. Offscreen and hidden tabs stop the cycle. Home’s personal cards share one integrated Quick Ask surface; mobile users can swipe through all five cards. The About photo fan lifts, tilts and pins individual photos with pointer, tap and keyboard controls. +Capabilities open the first row when visible, fill a bright green progress ring over four seconds, then cycle forward. Each detail is one concise paragraph with no hover state. Selecting any row closes the previous one and restarts the timer. Offscreen and hidden tabs stop the cycle. Home’s personal cards share one integrated Quick Ask surface; mobile users can switch between all five cards using tap buttons. The About photo fan lifts, tilts and pins individual photos with pointer, tap and keyboard controls. Desktop navigation is fully expanded at the top (within 1px), collapses as soon as scrolling moves downward, and reopens when returning to the top. A focused navigation link returns focus to the menu button on collapse. Width transitions use a restrained 4px opening overshoot and 3px closing undershoot, settling over 600ms and 500ms respectively. The menu button reopens it, and mobile keeps its compact initial menu. Navigation width changes use a short spring-shaped transition. Button presses, tabs, expandable content and cards have restrained motion. Reduced-motion preferences disable animated transitions and momentum without removing control feedback. The compact wallet shares a single white chat surface; messages load for at least two seconds, remain for ten seconds, and close on an outside click. -Wallet cards are 175px tall, their decorative top starts at 0px, and the wallet margin is 44px auto -20px at every breakpoint. Mobile retains a horizontally scrollable fan. +Desktop wallet cards are 175px tall, their decorative top starts at 0px, and the wallet margin is 44px auto -20px. Mobile uses a compact 192px card deck with a 124px card over two pastel layers. The profile card opens first; five 44px tap targets switch between Work, Me, Study, Notes, and Code without scrolling. The active card retains its original destination and all of its text; inactive links are hidden from focus and assistive technology. The deck has 24px top and 12px bottom spacing, followed by Quick Ask. Card changes use a brief spring entrance, disabled for reduced motion. Desktop keeps the five-card fan, and resizing preserves the focused destination. Without JavaScript, all five links appear in a compact two-column grid with no horizontal scrolling. diff --git a/index.html b/index.html index 53ccef2..70d33db 100644 --- a/index.html +++ b/index.html @@ -28,22 +28,23 @@ - + - +

SOFTWARE DEVELOPMENT & TEST AUTOMATION

Baivab Sarkar

I build thoughtful software
for real-world problems.

a little curious. a lot of code.

SCROLL TO EXPLORE

A FEW THINGS I’VE BUILT

Featured projects.

Open the folders. There’s good stuff inside.

FROM IDEA TO SOMETHING USEFUL

Good ideas deserve
thoughtful execution.

I connect development with testing, so the things I build feel good to use and hold up behind the scenes.

5 ways I put code to work

Web applicationsThoughtful interfaces, useful workflows, and the code behind them.

From Markdown Viewer’s local-first workspace to role-based academic and medicine supply-chain interfaces.

Test automationConfidence in the happy path. And all the other paths.

Page objects, data-driven checks, reports and continuous integration in my test automation practice.

Browser extensionsSmall tools that make the everyday web a little better.

NoteMarker keeps highlights and sticky notes on the pages that inspired them.

Java developmentA solid foundation, from objects to application logic.

Computer science fundamentals, hands-on SDET training, and team application development.

AI experimentsCuriosity, grounded in code and data.

A CSV question-answering prototype using retrieval and a pretrained Llama 2 model.

NOTES FROM THE PROCESS

Build. Learn. Write.

THE HUMAN BEHIND THE CODE

A little bit about me.

An idea, an opportunity, or just a hello?

Build with me.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file diff --git a/play-lab.html b/play-lab.html index ef4cb3e..1a7ad16 100644 --- a/play-lab.html +++ b/play-lab.html @@ -28,13 +28,13 @@ - + - +

Play Lab

diff --git a/project.html b/project.html index 81c634f..89f2b1c 100644 --- a/project.html +++ b/project.html @@ -28,12 +28,12 @@ - + - +

SELECTED WORK

A closer look at what
I've built.

12 projects

01—06 / FEATURED PROJECTS

07—12 / MORE WORK

Project archive.

PRIVATE FREELANCE WORK

Behind the scenes.

I have developed internal enterprise applications and business automation tools for private clients, supporting everyday operations, collaboration and documentation.

Confidentiality notice. These applications were developed as part of private freelance and enterprise engagements. Source code, system architecture, client data and certain project details cannot be disclosed due to confidentiality requirements.

An idea, an opportunity, or just a hello?

Build with me.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file diff --git a/script.js b/script.js index dca7a0a..b847aca 100644 --- a/script.js +++ b/script.js @@ -276,9 +276,34 @@ if (capabilityGroup) { const wallet = document.querySelector(".wallet"); if (wallet) { - wallet.querySelectorAll(".wallet-card").forEach((card) => { + const cards = [...wallet.querySelectorAll(".wallet-card")]; + const switcher = wallet.querySelector(".wallet-switcher"); + const choices = [...switcher.querySelectorAll("button")]; + let selected = "wallet-me"; + function showWalletCard(id = selected) { + selected = id; + cards.forEach((card) => { + card.hidden = smallScreen.matches && card.id !== selected; + }); + choices.forEach((button) => { + button.setAttribute("aria-pressed", String(button.getAttribute("aria-controls") === selected)); + }); + switcher.hidden = !smallScreen.matches; + } + choices.forEach((button) => { + button.addEventListener("click", () => showWalletCard(button.getAttribute("aria-controls"))); + }); + smallScreen.addEventListener("change", () => { + const focusedCard = cards.find((card) => card.contains(document.activeElement)); + const focusedChoice = switcher.contains(document.activeElement); + showWalletCard(focusedCard?.id || selected); + if (!smallScreen.matches && focusedChoice) document.getElementById(selected).focus(); + }); + wallet.dataset.walletReady = ""; + showWalletCard(); + cards.forEach((card) => { card.addEventListener("pointermove", (event) => { - if (reducedMotion.matches || event.pointerType === "touch") return; + if (smallScreen.matches || reducedMotion.matches || event.pointerType === "touch") return; const bounds = card.getBoundingClientRect(); card.style.setProperty( "--wallet-lean", diff --git a/scripts/build.mjs b/scripts/build.mjs index 013b1eb..cbe036e 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -120,13 +120,13 @@ function metadata(options = {}) { - + ${options.page ? '' : ""} ${!options.page ? '' : ""} - + ${options.page ? '' : ""} `.replace( /\n[ \t]+\n/g, diff --git a/scripts/dot-checks.mjs b/scripts/dot-checks.mjs index ee29588..c8208a0 100644 --- a/scripts/dot-checks.mjs +++ b/scripts/dot-checks.mjs @@ -145,12 +145,12 @@ export async function dotChecks(page, load) { })), }; }); - assert.equal(wallet.top, "44px", `Wallet top margin at ${width}px`); - assert.equal(wallet.bottom, "-20px", `Wallet overlap at ${width}px`); + assert.equal(wallet.top, width < 768 ? "24px" : "44px", `Wallet top margin at ${width}px`); + assert.equal(wallet.bottom, width < 768 ? "12px" : "-20px", `Wallet spacing at ${width}px`); assert( wallet.cards.length === 5 && wallet.cards.every( - (card) => card.height === "175px" && card.cap === "0px", + (card) => card.height === (width < 768 ? "124px" : "175px") && card.cap === "0px", ), `All wallet cards use the requested dimensions at ${width}px`, ); diff --git a/scripts/home-sections.mjs b/scripts/home-sections.mjs index 8c7cfc7..643f577 100644 --- a/scripts/home-sections.mjs +++ b/scripts/home-sections.mjs @@ -4,10 +4,11 @@ export function renderCapabilities(caps, heading) { export function renderHomeAbout(heading, quickAsk, link) { return `
${heading("THE HUMAN BEHIND THE CODE", "A little bit about me.", "about-title")}${quickAsk()}
${link("/about", "A little more about me", "button")}
`; } diff --git a/scripts/verify.mjs b/scripts/verify.mjs index df095a6..d878cbb 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -12,6 +12,7 @@ import { dotChecks } from "./dot-checks.mjs"; import { playgroundWidgetChecks } from "./playground-widget-checks.mjs"; import { playgroundThemeChecks } from "./playground-theme-checks.mjs"; import { themeChecks } from "./theme-checks.mjs"; +import { walletChecks } from "./wallet-checks.mjs"; const args = process.argv.slice(2); const option = (name, fallback) => @@ -1474,6 +1475,7 @@ try { () => playground(page), ], ["cursor highlights and reduced motion", () => cursorDots(page)], + ["compact mobile wallet, card switching and no-scroll fallback", () => walletChecks(browser, preview.url)], [ "tight dot masks, uniform edges, dark hover and compact wallet", () => dotChecks(page, load), diff --git a/scripts/wallet-checks.mjs b/scripts/wallet-checks.mjs new file mode 100644 index 0000000..dce85e7 --- /dev/null +++ b/scripts/wallet-checks.mjs @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; + +export async function walletChecks(browser, url) { + const context = await browser.newContext({ hasTouch: true }); + try { + const page = await context.newPage(); + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + const destinations = { + work: "/work/markdown-viewer", me: "/about", study: "/about#experience", + notes: "/blog", code: "https://github.com/ThisIs-Developer", + }; + for (const width of [320, 390, 440, 767]) { + await page.setViewportSize({ width, height: 956 }); + await page.goto("about:blank"); + await page.goto(`${url}/#about`, { waitUntil: "networkidle" }); + assert.equal(await page.locator(".wallet-card:visible").getAttribute("id"), "wallet-me"); + for (const dark of [false, true]) { + if (await page.locator(".theme-toggle").getAttribute("aria-pressed") !== String(dark)) + await page.locator(".theme-toggle").dispatchEvent("click"); + for (const [choice, href] of Object.entries(destinations)) { + const button = page.locator(`[data-wallet-select="${choice}"]`); + await button.tap(); + await page.waitForTimeout(450); + const card = page.locator(".wallet-card:visible"); + assert.equal(await card.count(), 1, "Only the selected link is exposed"); + assert.equal(await card.getAttribute("href"), href, "Every destination remains available"); + assert.equal(await button.getAttribute("aria-pressed"), "true"); + assert.equal(await page.locator('.wallet-switcher [aria-pressed="true"]').count(), 1); + const layout = await page.locator(".wallet").evaluate((wallet) => { + const selected = wallet.querySelector(".wallet-card:not([hidden])"); + const cardBox = selected.getBoundingClientRect(); + const switcher = wallet.querySelector(".wallet-switcher").getBoundingClientRect(); + return { + scrolls: wallet.scrollWidth > wallet.clientWidth || wallet.scrollHeight > wallet.clientHeight, + overflow: document.documentElement.scrollWidth > innerWidth, + height: wallet.getBoundingClientRect().height, + inBounds: cardBox.left >= 0 && cardBox.right <= innerWidth, + clearControls: cardBox.bottom <= switcher.top - 4, + clearAsk: document.querySelector(".quick-ask").getBoundingClientRect().top >= switcher.bottom, + targets: [...wallet.querySelectorAll("button")].every((el) => el.clientHeight >= 44 && el.clientWidth >= 44), + readable: [...selected.querySelectorAll("small, strong, span:last-child")].every((el) => { + const box = el.getBoundingClientRect(); + return box.left >= cardBox.left && box.right <= cardBox.right + && box.top >= cardBox.top && box.bottom <= cardBox.bottom; + }), + }; + }); + assert.deepEqual(layout, { + scrolls: false, overflow: false, height: 192, inBounds: true, + clearControls: true, clearAsk: true, targets: true, readable: true, + }, `${width}px ${dark ? "dark" : "light"} ${choice}: compact, readable, and no scrolling`); + } + } + } + await page.setViewportSize({ width: 440, height: 956 }); + await page.locator('[data-wallet-select="notes"]').focus(); + await page.keyboard.press("Space"); + assert.equal(await page.locator(".wallet-card:visible").getAttribute("id"), "wallet-notes"); + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.locator('[data-wallet-select="work"]').tap(); + assert.equal(await page.locator(".wallet-card:visible").evaluate(el => getComputedStyle(el).animationName), "none"); + await page.locator(".wallet-card:visible").tap(); + await page.waitForURL(`${url}/work/markdown-viewer`); + + await page.goto(`${url}/#about`, { waitUntil: "networkidle" }); + await page.locator('[data-wallet-select="notes"]').focus(); + await page.keyboard.press("Space"); + await page.setViewportSize({ width: 1440, height: 1000 }); + assert.equal(await page.locator(".wallet-card:visible").count(), 5, "Desktop keeps its full fan"); + assert.equal(await page.locator(".wallet-switcher").isVisible(), false); + assert.equal(await page.evaluate(() => document.activeElement.id), "wallet-notes", "Resize keeps focus on the selected destination"); + await page.locator("#wallet-code").focus(); + await page.setViewportSize({ width: 390, height: 956 }); + assert.equal(await page.locator(".wallet-card:visible").getAttribute("id"), "wallet-code", "Focused desktop card stays available after resizing"); + await page.locator("#quick-question").fill("hello"); + await page.locator("#quick-question").press("Enter"); + await page.locator('#ask-answer[data-state="ready"]').waitFor(); + assert.deepEqual(errors, [], "Wallet switching does not cause browser errors"); + } finally { + await context.close(); + } + + const noJs = await browser.newContext({ javaScriptEnabled: false, viewport: { width: 320, height: 956 } }); + try { + const page = await noJs.newPage(); + await page.goto(`${url}/#about`); + assert.equal(await page.locator(".wallet-card:visible").count(), 5, "All links are available without JavaScript"); + assert.equal(await page.locator(".wallet-switcher").isVisible(), false); + assert(await page.locator(".wallet").evaluate(el => el.scrollWidth <= el.clientWidth), "The fallback also avoids horizontal scrolling"); + } finally { + await noJs.close(); + } +} diff --git a/style.css b/style.css index 97eaabc..3c11617 100644 --- a/style.css +++ b/style.css @@ -2643,26 +2643,23 @@ input { height: 38px; } .wallet { - justify-content: flex-start; - max-width: 100%; - overflow-x: auto; - overflow-y: hidden; - padding: 43px 18px 22px; - margin: 44px auto -20px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: stretch; + gap: 12px; + max-width: 420px; + overflow: visible; + padding: 0; + margin: 24px auto 12px; width: 100%; - scroll-snap-type: x proximity; - scrollbar-width: none; - } - .wallet::-webkit-scrollbar { - display: none; } .wallet-card { - width: 156px; + width: 100%; height: 175px; - padding: 12px 15px 14px; - margin: 0 -4px; - scroll-snap-align: center; - flex-shrink: 0; + min-width: 0; + padding: 12px; + margin: 0; + transform: none; } .wallet-card small { font-size: 11px; @@ -2678,9 +2675,128 @@ input { height: 48px; } .wallet-card:is(:hover, :focus-visible) { - transform: translateY(-20px) rotate(0deg); + transform: translateY(-3px); + } + /* A pocket-sized deck: every destination is one tap away, without a scroller. */ + .wallet[data-wallet-ready] { + display: block; + position: relative; + height: 192px; + isolation: isolate; + } + .wallet[data-wallet-ready]::before, + .wallet[data-wallet-ready]::after { + content: ""; + position: absolute; + inset: 10px 18px auto; + height: 124px; + border-radius: 20px; + pointer-events: none; + background: #c9b4ed; + transform: rotate(-4deg) translateY(-4px); + box-shadow: inset 0 1px 0 #ffffff70; + z-index: -2; + } + .wallet[data-wallet-ready]::after { + background: #c5dda2; + transform: rotate(3deg) translateY(-1px); + z-index: -1; + } + .wallet[data-wallet-ready] .wallet-card { + position: absolute; + top: 12px; + left: 8px; + width: calc(100% - 16px); + height: 124px; + padding: 14px 16px; + display: grid; + grid-template-columns: 54px minmax(0, 1fr); + grid-template-rows: auto auto auto; + align-content: center; + column-gap: 14px; + row-gap: 4px; + border-radius: 20px; + transform: none; + animation: wallet-reveal 420ms var(--spring); + } + .wallet[data-wallet-ready] .wallet-card::after { + content: "↗"; + position: absolute; + top: 12px; + right: 14px; + font-size: 16px; + line-height: 1; + opacity: 0.6; + } + .wallet[data-wallet-ready] .wallet-card small { + grid-column: 2; + grid-row: 1; + margin: 0; + font-size: 11px; + } + .wallet[data-wallet-ready] :is(.wallet-icon, .wallet-card img) { + grid-column: 1; + grid-row: 1 / 4; + align-self: center; + width: 54px; + height: 58px; + border-radius: 14px; + } + .wallet[data-wallet-ready] .wallet-card strong { + grid-column: 2; + grid-row: 2; + margin: 0; + font-size: clamp(18px, 4.7vw, 22px); + line-height: 1.15; + overflow-wrap: anywhere; + } + .wallet[data-wallet-ready] .wallet-card > span:last-child { + grid-column: 2; + grid-row: 3; + margin: 0; + font-size: 12px; + } + .wallet[data-wallet-ready] .wallet-card:is(:hover, :focus-visible) { + transform: translateY(-3px); + } + .wallet-switcher { + position: absolute; + inset: auto 0 0; + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 4px; + z-index: 6; + } + .wallet-switcher button { + --wallet-choice: #dccbf4; + min-width: 0; + min-height: 44px; + padding: 6px 2px; + border: 0; + border-radius: 15px; + color: var(--muted); + background: var(--glass); + font-size: 11px; + font-weight: 600; + transition: background 200ms, color 200ms, transform 200ms var(--out); + } + .wallet-switcher [data-wallet-select="me"] { --wallet-choice: #f4dda0; } + .wallet-switcher [data-wallet-select="study"] { --wallet-choice: #c5dda2; } + .wallet-switcher [data-wallet-select="notes"] { --wallet-choice: #f1bdd2; } + .wallet-switcher [data-wallet-select="code"] { --wallet-choice: #bfd5f1; } + .wallet-switcher button[aria-pressed="true"] { + color: #302743; + background: var(--wallet-choice); + box-shadow: inset 0 1px 0 #ffffff75; + } + .wallet-switcher button:active { + transform: scale(0.96); } } +@keyframes wallet-reveal { + from { opacity: 0; transform: translateY(8px) rotate(-2deg); } + to { opacity: 1; transform: none; } +} @media (prefers-reduced-motion: reduce) { .capability[open] .capability-detail { animation: none; @@ -2692,6 +2808,15 @@ input { .wallet-card:is(:hover, :focus-visible) { transform: translateY(-8px); } + .wallet[data-wallet-ready] .wallet-card, + .wallet-switcher button { + animation: none; + transition: none; + } + .wallet[data-wallet-ready] .wallet-card:is(:hover, :focus-visible), + .wallet-switcher button:active { + transform: none; + } } /* Small labels stay readable at every breakpoint. */ .eyebrow, diff --git a/work.html b/work.html index 81c634f..89f2b1c 100644 --- a/work.html +++ b/work.html @@ -28,12 +28,12 @@ - + - +

SELECTED WORK

A closer look at what
I've built.

12 projects

01—06 / FEATURED PROJECTS

07—12 / MORE WORK

Project archive.

PRIVATE FREELANCE WORK

Behind the scenes.

I have developed internal enterprise applications and business automation tools for private clients, supporting everyday operations, collaboration and documentation.

Confidentiality notice. These applications were developed as part of private freelance and enterprise engagements. Source code, system architecture, client data and certain project details cannot be disclosed due to confidentiality requirements.

An idea, an opportunity, or just a hello?

Build with me.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file diff --git a/work/ams.html b/work/ams.html index c1d2c0b..42828d9 100644 --- a/work/ams.html +++ b/work/ams.html @@ -28,12 +28,12 @@ - + - +

Full-stack · Team project

AMS

Role-based attendance workflows for administrators, teachers and students.

Project
Team prototype
Period
2024
Built with
JavaScript, Spring, Redis
Academic Management System attendance dashboard on two mobile screens.

Overview

Role-based attendance workflows for administrators, teachers and students.

Contributed to a five-person academic management project.

The problem

Academic attendance records need coordinated access and reporting across institution roles.

My contribution

Contributed to a five-person academic management project.

JavaScriptSpringRedisPostgreSQLJPA

Inside the build

  • Role-specific attendance interfaces.
  • Attendance recording and report workflows.

Project notes

Team prototype

AMS is part of my work in full-stack · team project. The links above provide the available project and implementation details.

Next projectNoteMarker Extension

An idea, an opportunity, or just a hello?

Build with me.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file diff --git a/work/body-language.html b/work/body-language.html index afb32e7..6e00da9 100644 --- a/work/body-language.html +++ b/work/body-language.html @@ -28,12 +28,12 @@ - + - +

Computer vision · Notebook

Body Language Detection

A computer-vision experiment comparing landmark-based classification pipelines.

Project
Archived research experiment
Period
2023
Built with
Python, MediaPipe, OpenCV
Bar chart of labeled samples from the Body Language Detection experiment.

Overview

A computer-vision experiment comparing landmark-based classification pipelines.

Built notebooks for collecting labeled landmark data, training classifiers and exporting models.

The problem

Explore how video landmarks can be collected and used as classifier inputs.

My contribution

Built notebooks for collecting labeled landmark data, training classifiers and exporting models.

PythonMediaPipeOpenCVscikit-learnTensorFlow

Inside the build

  • Collect labeled landmark data from webcam or recorded video.
  • Compare scikit-learn pipelines and a TensorFlow Lite export.

Project notes

Archived research experiment

Body Language Detection is part of my work in computer vision · notebook. The links above provide the available project and implementation details.

Next projectMediChain

An idea, an opportunity, or just a hello?

Build with me.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file diff --git a/work/csv-chatbot.html b/work/csv-chatbot.html index b9d0331..e9e8a5d 100644 --- a/work/csv-chatbot.html +++ b/work/csv-chatbot.html @@ -28,12 +28,12 @@ - + - +

AI · Retrieval experiment

Llama 2 GGML CSV Chatbot

Ask questions of uploaded CSV data using local model inference and vector retrieval.

Project
Research prototype
Period
2024
Built with
Python, LangChain, Streamlit
Llama 2 GGML CSV Chatbot — replaceable project image
Project image placeholder

Overview

Ask questions of uploaded CSV data using local model inference and vector retrieval.

Built a Streamlit prototype combining CSV ingestion, embedding search and a quantized Llama 2 model.

The problem

Exploring tabular data through natural language requires grounding responses in the uploaded file.

My contribution

Built a Streamlit prototype combining CSV ingestion, embedding search and a quantized Llama 2 model.

PythonLangChainStreamlitLlama 2FAISS

Inside the build

  • Chunk CSV data and index it with sentence-transformer embeddings and FAISS.
  • Retrieve relevant rows for a Llama 2 question-answering workflow.

Project notes

Research prototype

Llama 2 GGML CSV Chatbot is part of my work in ai · retrieval experiment. The links above provide the available project and implementation details.

Next projectNews Scraping Platform

An idea, an opportunity, or just a hello?

Build with me.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file diff --git a/work/enterprise.html b/work/enterprise.html index 64a721a..b20c7f3 100644 --- a/work/enterprise.html +++ b/work/enterprise.html @@ -28,12 +28,12 @@ - + - +

Private freelance work

Enterprise applications.

I have developed internal enterprise applications and business automation tools for private clients, supporting everyday operations, collaboration and documentation.

Engagement
Private freelance
Focus
Business automation
Availability
Confidential

Finance workflows

Enterprise Invoice Management System

An internal application for managing company invoices, records and related business workflows.

Team workflows

Team Collaboration & Workflow Platform

An internal collaboration application designed to improve communication, task coordination and team productivity.

Document automation

Proposal Document Generator

An automated system for generating standardized business proposals and formatted project documentation.

Audit workflows

Audit Management & Compliance System

Internal software supporting audit and compliance workflows, documentation, tracking and reporting, including contributions to audit and project environments associated with NTPC, Bluestar and other enterprise clients.

Explore public work

An idea, an opportunity, or just a hello?

Build with me.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file diff --git a/work/markdown-viewer.html b/work/markdown-viewer.html index ebf3e57..25a70de 100644 --- a/work/markdown-viewer.html +++ b/work/markdown-viewer.html @@ -28,12 +28,12 @@ - + - +

Developer tools · Open source

Markdown Viewer

A local-first Markdown workspace, from the first draft to the shared document.

Project
Actively maintained
Period
2024–2026
Built with
JavaScript, IndexedDB, Cloudflare
Markdown Viewer workspace showing its document explorer, editor and rendered preview.

Overview

A local-first Markdown workspace, from the first draft to the shared document.

Created and maintain the editor, its document workspace, rendering workflows and web/desktop delivery.

The problem

Technical writing needs more than a preview: documents, diagrams, review and sharing should work together.

My contribution

Created and maintain the editor, its document workspace, rendering workflows and web/desktop delivery.

JavaScriptIndexedDBCloudflareNeutralinojsPlaywright

Inside the build

  • Folders, tabs, IndexedDB autosave and encrypted workspace storage.
  • Rich rendering, anchored review comments and optional Live Share.

Project notes

Actively maintained

Markdown Viewer is part of my work in developer tools · open source. The links above provide the available project and implementation details.

Next projectSei-Sangeet-Bangla

An idea, an opportunity, or just a hello?

Build with me.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file diff --git a/work/medical-chatbot.html b/work/medical-chatbot.html index 75dc0d2..85f3ff6 100644 --- a/work/medical-chatbot.html +++ b/work/medical-chatbot.html @@ -28,12 +28,12 @@ - + - +

AI · Retrieval experiment

Llama 2 GGML Medical Chatbot

An experiment in answering questions from medical reference documents with a quantized language model.

Project
Research prototype
Period
2023–2024
Built with
Python, Llama 2, LangChain
Llama 2 medical document retrieval prototype interface

Overview

An experiment in answering questions from medical reference documents with a quantized language model.

Built the retrieval workflow, model integration and chatbot interface using Python and LangChain.

The problem

Explore document-grounded question answering using local model inference and retrieved source passages.

My contribution

Built the retrieval workflow, model integration and chatbot interface using Python and LangChain.

PythonLlama 2LangChainFAISSChainlit

Inside the build

  • Retrieve document passages using sentence-transformer embeddings and FAISS.
  • Pass retrieved context to a pretrained Llama 2 model and return source documents with the answer.

Project notes

Research prototype

This is a document-retrieval experiment, not a clinical system or a substitute for professional medical advice. It uses a pretrained model; no model-training or clinical-accuracy claim is made.

Next projectBody Language Detection

An idea, an opportunity, or just a hello?

Build with me.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file diff --git a/work/medichain.html b/work/medichain.html index 5cacdcd..e78b6dd 100644 --- a/work/medichain.html +++ b/work/medichain.html @@ -28,12 +28,12 @@ - + - +

Full stack · Blockchain prototype

MediChain

Making a medicine's journey visible across a pharmaceutical supply chain.

Project
Team prototype
Period
2024–2025
Built with
JavaScript, Solidity, Ethereum
MediChain dashboard showing medicine stock and supply-chain records.

Overview

Making a medicine's journey visible across a pharmaceutical supply chain.

Led full-stack development in a four-person team, connecting the web interface with blockchain workflows.

The problem

Medicine registration, inventory and purchase history need a shared record across supply-chain roles.

My contribution

Led full-stack development in a four-person team, connecting the web interface with blockchain workflows.

JavaScriptSolidityEthereumWeb3.jsMetaMask

Inside the build

  • Track medicine batches, lifecycle states and purchase history.
  • Connect role-based web interfaces to Ethereum contracts.

Project notes

Team prototype

MediChain is part of my work in full stack · blockchain prototype. The links above provide the available project and implementation details.

Next projectAMS

An idea, an opportunity, or just a hello?

Build with me.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file diff --git a/work/news-scraper.html b/work/news-scraper.html index 2a1c6b1..c84aa52 100644 --- a/work/news-scraper.html +++ b/work/news-scraper.html @@ -28,12 +28,12 @@ - + - +

Automation · Python

News Scraping Platform

A Django experiment for collecting news articles from multiple sources.

Project
Archived experiment
Period
2023–2024
Built with
Python, Django, Selenium
News Scraping Platform — replaceable project image
Project image placeholder

Overview

A Django experiment for collecting news articles from multiple sources.

Built scraping workflows with BeautifulSoup and Selenium, integrated into Django.

The problem

Repeated article collection can be automated into a consistent data workflow.

My contribution

Built scraping workflows with BeautifulSoup and Selenium, integrated into Django.

PythonDjangoSeleniumBeautifulSoupMySQL

Inside the build

  • Concurrent collection from multiple publications.
  • Export collected records to Excel or MySQL.

Project notes

Archived experiment

News Scraping Platform is part of my work in automation · python. The links above provide the available project and implementation details.

Next projectTaskFlow

An idea, an opportunity, or just a hello?

Build with me.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file diff --git a/work/notemarker.html b/work/notemarker.html index aa50c83..4cf9996 100644 --- a/work/notemarker.html +++ b/work/notemarker.html @@ -28,12 +28,12 @@ - + - +

Browser extension · Productivity

NoteMarker Extension

Keep the useful parts of the web, right where you found them.

Project
Released for Firefox & Chromium
Period
2024
Built with
JavaScript, WebExtensions, Browser Storage
NoteMarker highlights and sticky notes on a research paper webpage.

Overview

Keep the useful parts of the web, right where you found them.

Built and maintain an open-source extension for highlighting webpages and creating persistent notes.

The problem

Research gets fragmented when highlights and notes are separated from the pages that inspired them.

My contribution

Built and maintain an open-source extension for highlighting webpages and creating persistent notes.

JavaScriptWebExtensionsBrowser StorageHTML & CSS

Inside the build

  • Persistent five-color highlights and rich-text sticky notes.
  • Move, resize and manage annotations on the original webpage.

Project notes

Released for Firefox & Chromium

NoteMarker Extension is part of my work in browser extension · productivity. The links above provide the available project and implementation details.

Next projectSketchFlow

An idea, an opportunity, or just a hello?

Build with me.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file diff --git a/work/sei-sangeet-bangla.html b/work/sei-sangeet-bangla.html index d0e9efd..f4bf430 100644 --- a/work/sei-sangeet-bangla.html +++ b/work/sei-sangeet-bangla.html @@ -28,12 +28,12 @@ - + - +

Web app · Music

Sei-Sangeet-Bangla

A Bengali music experience set inside an illustrated room, with a song list and a familiar player.

Project
Private source
Period
2026
Built with
Web development
Sei-Sangeet-Bangla music room with Bengali song list and player

Overview

A Bengali music experience set inside an illustrated room, with a song list and a familiar player.

Developed Sei-Sangeet-Bangla as a personal music project.

The problem

Create a dedicated web experience around Bengali music.

My contribution

Developed Sei-Sangeet-Bangla as a personal music project.

Web development

Inside the build

  • Browse the song list inside a nostalgic, illustrated Bengali room.
  • Use track, playback, seek and volume controls in the music player.

Project notes

Private source

The project is included in my portfolio, while its source repository remains private.

Next projectLlama 2 GGML Medical Chatbot

An idea, an opportunity, or just a hello?

Build with me.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file diff --git a/work/simon.html b/work/simon.html index e5aeab2..b0d0037 100644 --- a/work/simon.html +++ b/work/simon.html @@ -28,12 +28,12 @@ - + - +

Web app · Memory game

Simon

A browser memory game built around color, sound and an increasingly long sequence to remember.

Project
Open-source game
Period
2024
Built with
JavaScript, HTML & CSS, localStorage
Simon — replaceable project image
Project image placeholder

Overview

A browser memory game built around color, sound and an increasingly long sequence to remember.

Built the game interface, randomized sequence logic, audio feedback and local high-score tracking in JavaScript.

The problem

Recreate the classic Simon memory challenge with immediate visual feedback and simple browser controls.

My contribution

Built the game interface, randomized sequence logic, audio feedback and local high-score tracking in JavaScript.

JavaScriptHTML & CSSlocalStorage

Inside the build

  • Follow flashing colors and repeat the growing sequence to advance through levels.
  • Hear feedback on button presses and mistakes, restart a round and keep the highest score in the current browser.

Project notes

Open-source game

The high score is saved locally in the current browser. The game is a personal implementation of the classic Simon memory challenge.

Next projectMarkdown Viewer

An idea, an opportunity, or just a hello?

Build with me.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file diff --git a/work/sketchflow.html b/work/sketchflow.html index 302fc2d..46bdb83 100644 --- a/work/sketchflow.html +++ b/work/sketchflow.html @@ -28,12 +28,12 @@ - + - +

Canvas · Creative tool

SketchFlow

A browser canvas for sketching ideas, diagrams and freehand drawings.

Project
Open-source experiment
Period
2024–2026
Built with
JavaScript, Canvas API, HTML & CSS
SketchFlow drawing canvas with a colorful circular sketch.

Overview

A browser canvas for sketching ideas, diagrams and freehand drawings.

Built the canvas interaction, shape tools and drawing controls in vanilla JavaScript.

The problem

Quick visual thinking benefits from a lightweight drawing workspace.

My contribution

Built the canvas interaction, shape tools and drawing controls in vanilla JavaScript.

JavaScriptCanvas APIHTML & CSS

Inside the build

  • Draw shapes, arrows, text and freehand strokes.
  • Adjust styling and zoom; export PNG or save JSON.

Project notes

Open-source experiment

SketchFlow is part of my work in canvas · creative tool. The links above provide the available project and implementation details.

Next projectLlama 2 GGML CSV Chatbot

An idea, an opportunity, or just a hello?

Build with me.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file diff --git a/work/taskflow.html b/work/taskflow.html index 6662fac..91e7e5f 100644 --- a/work/taskflow.html +++ b/work/taskflow.html @@ -28,12 +28,12 @@ - + - +

Developer tools · Productivity

TaskFlow

A flexible task board for organizing work, one column and one card at a time.

Project
Open-source tool
Period
2024–2025
Built with
JavaScript, HTML & CSS, Bootstrap
TaskFlow task cards arranged in To Do, In Progress and Done columns

Overview

A flexible task board for organizing work, one column and one card at a time.

Built a browser-based task management interface with customizable columns and drag-and-drop task cards.

The problem

Keep tasks, priorities and supporting details together in a lightweight visual workflow.

My contribution

Built a browser-based task management interface with customizable columns and drag-and-drop task cards.

JavaScriptHTML & CSSBootstraplocalStorage

Inside the build

  • Organize tasks with due dates, priorities, assignees, tags, comments and checklists.
  • Search tasks, move cards between columns and save the board in browser localStorage.

Project notes

Open-source tool

The board is stored in the current browser. Assignee fields help organize work; they do not imply a hosted multi-user collaboration backend.

Next projectSimon

An idea, an opportunity, or just a hello?

Build with me.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file From 1a2b708335aa5b557aeffa53a136fe1761e679e4 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Mon, 14 Sep 2026 15:24:44 +0530 Subject: [PATCH 19/22] fix(portfolio): match wallet reference and polish dark page details --- 404.html | 8 +- about.html | 8 +- assets/icons/dev-badge.svg | 1 + blog.html | 8 +- ...er-your-desktop-and-a-single-url-488m.html | 10 +- ...uide-to-development-and-features-4gpf.html | 10 +- blog/do-you-know-llama--3c2d.html | 10 +- blog/epic-games-membership-on-github-3ml.html | 10 +- blog/introducing-markdown-viewer-v20-com.html | 10 +- blog/llama-2-ggml-medical-chatbot-5043.html | 10 +- ...encvthe-enchanted-palette-part-2-424k.html | 10 +- ...cvunleashing-the-magic-of-images-1kdb.html | 10 +- blog/portfolio-thisis-developer-56bf.html | 10 +- ...rd-and-process-audio-with-python-2a3b.html | 10 +- ...github-commits-with-verification-3hja.html | 10 +- docs/design-system.md | 8 +- editorial.css | 10 + index.html | 11 +- play-lab.html | 8 +- project.html | 8 +- script.js | 29 +-- scripts/build.mjs | 9 +- scripts/dot-checks.mjs | 6 +- scripts/home-sections.mjs | 5 +- scripts/site-pages.mjs | 4 +- scripts/templates.mjs | 2 +- scripts/theme-checks.mjs | 4 +- scripts/verify.mjs | 2 +- scripts/wallet-checks.mjs | 98 ++++----- style.css | 197 +++++------------- theme.js | 2 +- work.html | 8 +- work/ams.html | 8 +- work/body-language.html | 8 +- work/csv-chatbot.html | 8 +- work/enterprise.html | 10 +- work/markdown-viewer.html | 8 +- work/medical-chatbot.html | 8 +- work/medichain.html | 8 +- work/news-scraper.html | 8 +- work/notemarker.html | 8 +- work/sei-sangeet-bangla.html | 8 +- work/simon.html | 8 +- work/sketchflow.html | 8 +- work/taskflow.html | 8 +- 45 files changed, 264 insertions(+), 388 deletions(-) create mode 100644 assets/icons/dev-badge.svg diff --git a/404.html b/404.html index f4ded89..9924f9c 100644 --- a/404.html +++ b/404.html @@ -7,7 +7,7 @@ - + @@ -28,12 +28,12 @@ - + - + - +

404

A small detour.

This page took a wrong turn.
Jump a few bugs, then find your way back.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file diff --git a/about.html b/about.html index e5467a5..e6a21a0 100644 --- a/about.html +++ b/about.html @@ -7,7 +7,7 @@ - + @@ -28,12 +28,12 @@ - + - + - +

Hey, I’m Baivab.

I build useful software, follow my curiosity,
and keep learning along the way.

Hover or select a photo to bring it forward. Use the arrow keys to explore, and Escape to put it back.

I like turning everyday friction into useful software. A better place to write Markdown. A way to trace a medicine’s journey. A test that catches the next broken workflow.

I’m Baivab, a software developer building open-source tools, thoughtful web experiences, and the tests that keep them reliable.

My work connects Java and JavaScript development with a growing practice in test automation. I care about the whole journey: how something works, how it feels to use, and what happens when it breaks.

CURRENTLY

West Bengal, India

Building tools. Learning in public.

THINGS I ENJOY WORKING WITH

Open sourceJavaScriptJavaTest automationBrowser toolsComputer scienceDeveloper experienceCreative coding
Creative codingDeveloper experienceComputer scienceBrowser toolsTest automationJavaJavaScriptOpen source

SELECTED TOOLS

JavaScriptPythonJavaSeleniumTensorFlowOpenCVDockerJenkinsGitCloudflareVercelFigma

What I enjoy working on

I’m drawn to the small problems that keep getting in the way: a document that needs a better workspace, information that needs to move between people, or a workflow that needs reliable tests.

That has taken me from Markdown Viewer and NoteMarker to MediChain, academic applications and private enterprise tools.

My process

I start with the workflow: what someone needs to do, where the friction is, and what a useful first version would look like. Then I build, test and refine the details.

My Java and Selenium training has made testing part of that process. I want the interface and the behavior behind it to make sense together.

Learning in public

My public repositories and blog are a record of that learning. There are polished tools, team prototypes and earlier experiments. Each has something that led to the next one.

Read my notes

Experience & learning

2024 — ongoing

Building in the open

Independent projects · Open source

Developing Markdown Viewer and other tools, working through product details from responsive interfaces to document rendering and collaboration.

Explore the source

Training completed

Java & Selenium QA automation

Hands-on SDET training

UI and API testing with Selenium WebDriver, TestNG, JUnit, Cucumber, Postman, and Jenkins. Practiced Page Object Model and repeatable test workflows.

Oct 2021 — May 2025

B.Tech, Computer Science & Engineering

JIS College of Engineering

Graduated with a CGPA of 9.15/10. Built a foundation in object-oriented programming, data structures, algorithms, databases, and the software development lifecycle.

PRIVATE FREELANCE WORK

Enterprise applications & automation

I have developed internal enterprise applications and business automation tools for private clients, supporting everyday operations, collaboration and documentation.

Private work overview

GitHub FoundationsNov 2024

Java Programming · UdemyMay 2025

Data Structures & Algorithms · UdemySep 2024

An idea, an opportunity, or just a hello?

Build with me.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file diff --git a/assets/icons/dev-badge.svg b/assets/icons/dev-badge.svg new file mode 100644 index 0000000..9fa42ff --- /dev/null +++ b/assets/icons/dev-badge.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/blog.html b/blog.html index 2868c77..c3b28ce 100644 --- a/blog.html +++ b/blog.html @@ -7,7 +7,7 @@ - + @@ -28,12 +28,12 @@ - + - + - +

ENGINEERING & IDEAS

Built with care.
Shared in detail.

Inside the software I build: product decisions, practical guides and lessons from working with code.

11 posts

Markdown Viewer: one workspace across browser and desktop

A closer look at document tabs, desktop delivery, GitHub imports and sharing documents through a URL.

Baivab Sarkar

Inside Markdown Viewer 2.0

Extending a live-preview editor with diagrams, mathematical notation, syntax highlighting and a more complete writing interface.

Baivab Sarkar

Managing public membership in large GitHub organizations

A practical guide to changing organization membership visibility when the member directory is difficult to navigate.

Baivab Sarkar

Signing Git commits with GPG

Generate a GPG key, configure Git signing and verify signatures in your commit history.

Baivab Sarkar

Building a Markdown viewer for the web

The foundations of a browser-based Markdown editor: document input, rendered output and a practical setup workflow.

Baivab Sarkar

Question answering over CSV data with Llama 2

An experimental retrieval workflow that combines CSV documents, a pretrained language model and a chat interface.

Baivab Sarkar

Building a document-retrieval prototype with Llama 2

A Python experiment connecting reference passages to a pretrained language model through a conversational interface.

Baivab Sarkar

Image processing with OpenCV: filters, contours and histograms

Notebook examples covering resizing, morphology, filtering, thresholding and histogram analysis.

Baivab Sarkar

Getting started with images in OpenCV

A compact notebook-based introduction to loading and working with images using Python and OpenCV.

Baivab Sarkar

Recording and analysing audio with Python

Python examples for recording audio, loading files, plotting waveforms and generating spectrograms.

Baivab Sarkar

Connecting a portfolio contact form to Google Sheets

A historical implementation using browser JavaScript and Google Apps Script to store contact-form submissions.

Baivab Sarkar

An idea, an opportunity, or just a hello?

Build with me.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file diff --git a/blog/a-markdown-editor-that-lives-in-your-browser-your-desktop-and-a-single-url-488m.html b/blog/a-markdown-editor-that-lives-in-your-browser-your-desktop-and-a-single-url-488m.html index 7327d4f..ef3b3d7 100644 --- a/blog/a-markdown-editor-that-lives-in-your-browser-your-desktop-and-a-single-url-488m.html +++ b/blog/a-markdown-editor-that-lives-in-your-browser-your-desktop-and-a-single-url-488m.html @@ -7,7 +7,7 @@ - + @@ -28,13 +28,13 @@ - + - + - +

Projects

Markdown Viewer: one workspace across browser and desktop

A closer look at document tabs, desktop delivery, GitHub imports and sharing documents through a URL.

The latest Markdown Viewer rebuild brings document management, rendering and sharing into one workspace. This article explains the changes to multi-tab sessions, desktop delivery, GitHub imports and URL-based sharing, alongside the decisions behind them.

@@ -519,4 +519,4 @@

What's Next?

-

Written by Baivab Sarkar.

Publication history
Continue readingInside Markdown Viewer 2.0

An idea, an opportunity, or just a hello?

Build with me.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file +
View inDEV
Continue readingInside Markdown Viewer 2.0

An idea, an opportunity, or just a hello?

Build with me.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file diff --git a/blog/building-a-markdown-viewer-web-application-a-comprehensive-guide-to-development-and-features-4gpf.html b/blog/building-a-markdown-viewer-web-application-a-comprehensive-guide-to-development-and-features-4gpf.html index c3eecc1..8e041d3 100644 --- a/blog/building-a-markdown-viewer-web-application-a-comprehensive-guide-to-development-and-features-4gpf.html +++ b/blog/building-a-markdown-viewer-web-application-a-comprehensive-guide-to-development-and-features-4gpf.html @@ -7,7 +7,7 @@ - + @@ -28,13 +28,13 @@ - + - + - +

Projects

Building a Markdown viewer for the web

The foundations of a browser-based Markdown editor: document input, rendered output and a practical setup workflow.

A Markdown viewer connects a plain-text writing surface to rendered output. This guide introduces the format, walks through the features of the original web application and shows how to run the project locally.

@@ -197,4 +197,4 @@

Quick Setup Guide:

-

Image description

Written by Baivab Sarkar.

Publication history
Continue readingQuestion answering over CSV data with Llama 2

An idea, an opportunity, or just a hello?

Build with me.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file +

Image description

View inDEV
Continue readingQuestion answering over CSV data with Llama 2

An idea, an opportunity, or just a hello?

Build with me.

Bug Run

A tiny break between builds.

Score0
Best0
An optional jumping game. The rest of this portfolio works without JavaScript.

Space or tap to start. Dodge a few bugs.

\ No newline at end of file diff --git a/blog/do-you-know-llama--3c2d.html b/blog/do-you-know-llama--3c2d.html index 4062a9f..0320716 100644 --- a/blog/do-you-know-llama--3c2d.html +++ b/blog/do-you-know-llama--3c2d.html @@ -7,7 +7,7 @@ - + @@ -28,13 +28,13 @@ - + - + - +

AI & data

Question answering over CSV data with Llama 2

An experimental retrieval workflow that combines CSV documents, a pretrained language model and a chat interface.

This project explores question answering over CSV data using a pretrained, quantized Llama 2 model. Retrieval supplies relevant document context; the project does not train or fine-tune the model on the uploaded data.

Llama2 CSV ChatBot

@@ -192,4 +192,4 @@

Important Notes

  • While robust, this chatbot is not a substitute for professional advice.
  • Ensure the CSV file adheres to the expected format for optimal performance.
  • -

    Written by Baivab Sarkar.

    Publication history
    Continue readingBuilding a document-retrieval prototype with Llama 2

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file +
    View inDEV
    Continue readingBuilding a document-retrieval prototype with Llama 2

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/blog/epic-games-membership-on-github-3ml.html b/blog/epic-games-membership-on-github-3ml.html index d9d16ac..f17e9e9 100644 --- a/blog/epic-games-membership-on-github-3ml.html +++ b/blog/epic-games-membership-on-github-3ml.html @@ -7,7 +7,7 @@ - + @@ -28,13 +28,13 @@ - + - + - +

    Git & tooling

    Managing public membership in large GitHub organizations

    A practical guide to changing organization membership visibility when the member directory is difficult to navigate.

    Organization membership visibility determines whether an organization appears on your public GitHub profile. This guide documents a workflow for changing that setting in a large organization, using Epic Games as the example.

    GitHub Fun Gif

    @@ -175,4 +175,4 @@

    A Word of Caution: Protect Y -

    Success Gif

    Written by Baivab Sarkar.

    Publication history
    Continue readingSigning Git commits with GPG

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file +

    Success Gif

    View inDEV
    Continue readingSigning Git commits with GPG

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/blog/introducing-markdown-viewer-v20-com.html b/blog/introducing-markdown-viewer-v20-com.html index 9368f57..5004c30 100644 --- a/blog/introducing-markdown-viewer-v20-com.html +++ b/blog/introducing-markdown-viewer-v20-com.html @@ -7,7 +7,7 @@ - + @@ -28,13 +28,13 @@ - + - + - +

    Projects

    Inside Markdown Viewer 2.0

    Extending a live-preview editor with diagrams, mathematical notation, syntax highlighting and a more complete writing interface.

    Markdown Viewer 2.0 expands the original split-pane editor with diagrams, mathematical notation, syntax highlighting and an updated interface. This release overview covers the features added to support a broader technical-writing workflow.

    @@ -289,4 +289,4 @@

    Final Thoughts

    -

    Written by Baivab Sarkar.

    Publication history
    Continue readingManaging public membership in large GitHub organizations

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file +
    View inDEV
    Continue readingManaging public membership in large GitHub organizations

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/blog/llama-2-ggml-medical-chatbot-5043.html b/blog/llama-2-ggml-medical-chatbot-5043.html index 13fa4d5..762f2db 100644 --- a/blog/llama-2-ggml-medical-chatbot-5043.html +++ b/blog/llama-2-ggml-medical-chatbot-5043.html @@ -7,7 +7,7 @@ - + @@ -28,13 +28,13 @@ - + - + - +

    AI & data

    Building a document-retrieval prototype with Llama 2

    A Python experiment connecting reference passages to a pretrained language model through a conversational interface.

    This prototype retrieves passages from a medical reference document and supplies them to a pretrained, quantized Llama 2 model. It is an engineering experiment in retrieval and interface design. It has not been clinically validated and is not intended for medical decision-making.

    @@ -119,4 +119,4 @@

    PDF

    Conversation examples img-3.pdf

    Image description

    Important Notes

    -
    • While powerful, this chatbot isn't a substitute for professional medical advice.

    Written by Baivab Sarkar.

    Publication history
    Continue readingImage processing with OpenCV: filters, contours and histograms

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file +
    • While powerful, this chatbot isn't a substitute for professional medical advice.
    View inDEV
    Continue readingImage processing with OpenCV: filters, contours and histograms

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/blog/opencvthe-enchanted-palette-part-2-424k.html b/blog/opencvthe-enchanted-palette-part-2-424k.html index beef05c..c9cec86 100644 --- a/blog/opencvthe-enchanted-palette-part-2-424k.html +++ b/blog/opencvthe-enchanted-palette-part-2-424k.html @@ -7,7 +7,7 @@ - + @@ -28,13 +28,13 @@ - + - + - +

    AI & data

    Image processing with OpenCV: filters, contours and histograms

    Notebook examples covering resizing, morphology, filtering, thresholding and histogram analysis.

    This collection extends the basic image-loading workflow with common OpenCV processing operations. The examples cover resizing, morphology, contours, filtering, thresholding and histogram analysis, with links to the corresponding notebook.

    Embark on a Visual Journey: Explore the OpenCV Chronicles in my GitHub Repository! #OpenCVMagic #ImageAlchemy #CodeCanvas
    Source GitHub: ThisIs-Developer/Python/OpenCV/Image Processing
    @@ -104,4 +104,4 @@

    Analyze an image using Histogr -

    Image description

    Written by Baivab Sarkar.

    Publication history
    Continue readingGetting started with images in OpenCV

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file +

    Image description

    View inDEV
    Continue readingGetting started with images in OpenCV

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/blog/opencvunleashing-the-magic-of-images-1kdb.html b/blog/opencvunleashing-the-magic-of-images-1kdb.html index 51091f1..2d5d369 100644 --- a/blog/opencvunleashing-the-magic-of-images-1kdb.html +++ b/blog/opencvunleashing-the-magic-of-images-1kdb.html @@ -7,7 +7,7 @@ - + @@ -28,13 +28,13 @@ - + - + - +

    AI & data

    Getting started with images in OpenCV

    A compact notebook-based introduction to loading and working with images using Python and OpenCV.

    This notebook collection introduces working with images in Python using OpenCV. Install the package, open the linked notebook and follow the accompanying examples to explore the image-processing workflow.

    @@ -52,4 +52,4 @@

    Image description
    Image description
    -Image description

    Written by Baivab Sarkar.

    Publication history
    Continue readingRecording and analysing audio with Python

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file +Image description

    View inDEV
    Continue readingRecording and analysing audio with Python

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/blog/portfolio-thisis-developer-56bf.html b/blog/portfolio-thisis-developer-56bf.html index 668b630..795f620 100644 --- a/blog/portfolio-thisis-developer-56bf.html +++ b/blog/portfolio-thisis-developer-56bf.html @@ -7,7 +7,7 @@ - + @@ -28,13 +28,13 @@ - + - + - +

    Notes

    Connecting a portfolio contact form to Google Sheets

    A historical implementation using browser JavaScript and Google Apps Script to store contact-form submissions.

    An earlier version of my portfolio used browser JavaScript and Google Apps Script to send contact-form submissions to a spreadsheet. This article records that implementation and its interface states; it describes the older project, rather than the contact flow on this website.

    Website isLive:baivabsarkar-old.vercel.app

    @@ -113,4 +113,4 @@

    After sending Message.

    Drop a Comment to Show Your Support and Connect. Let's Make Learning and Creating Together! #ShareYourThoughts #EngageAndConnect
    -Image description

    Written by Baivab Sarkar.

    Publication history
    Continue readingMarkdown Viewer: one workspace across browser and desktop

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file +Image description

    View inDEV
    Continue readingMarkdown Viewer: one workspace across browser and desktop

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/blog/record-and-process-audio-with-python-2a3b.html b/blog/record-and-process-audio-with-python-2a3b.html index 6a2431c..541d133 100644 --- a/blog/record-and-process-audio-with-python-2a3b.html +++ b/blog/record-and-process-audio-with-python-2a3b.html @@ -7,7 +7,7 @@ - + @@ -28,13 +28,13 @@ - + - + - +

    AI & data

    Recording and analysing audio with Python

    Python examples for recording audio, loading files, plotting waveforms and generating spectrograms.

    These examples bring together audio recording, playback and analysis in Python. The code covers loading audio, examining sample data, plotting waveforms and generating spectrograms with Librosa and Matplotlib.

    @@ -91,4 +91,4 @@ plot_spectrogram(audio_data, sample_rate, output_path) print("Plots saved as 'waveform.png' and 'spectrogram.png' in the current directory.") -

    Written by Baivab Sarkar.

    Publication history
    Continue readingConnecting a portfolio contact form to Google Sheets

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file +
    View inDEV
    Continue readingConnecting a portfolio contact form to Google Sheets

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/blog/secure-your-github-commits-with-verification-3hja.html b/blog/secure-your-github-commits-with-verification-3hja.html index 4235113..6897854 100644 --- a/blog/secure-your-github-commits-with-verification-3hja.html +++ b/blog/secure-your-github-commits-with-verification-3hja.html @@ -7,7 +7,7 @@ - + @@ -28,13 +28,13 @@ - + - + - +

    Git & tooling

    Signing Git commits with GPG

    Generate a GPG key, configure Git signing and verify signatures in your commit history.

    Signed commits attach a cryptographic signature to a Git commit. This walkthrough covers generating a GPG key, adding its public component to GitHub, configuring Git and checking the resulting signatures.

    How to Set Up a GPG Key for Signed Git Commits: A Step-by-Step Guide

    @@ -310,4 +310,4 @@

    Congratulations!

    -

    Image description

    Written by Baivab Sarkar.

    Publication history
    Continue readingBuilding a Markdown viewer for the web

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file +

    Image description

    View inDEV
    Continue readingBuilding a Markdown viewer for the web

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/docs/design-system.md b/docs/design-system.md index 9b0a669..a56097c 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -60,8 +60,12 @@ Each binary's WOFF2 signature was verified. The SIL Open Font License texts (wit The generated SVGs in assets/about and assets/placeholders are explicitly labeled placeholders. To replace personal photos, update the photo strip in scripts/site-pages.mjs and publish the matching image asset. To replace a project image, set its image, imageAlt, imageWidth and imageHeight in the project JSON and supply the responsive variants used by the folder renderer. Keep real screenshots separate from private enterprise summaries. -Capabilities open the first row when visible, fill a bright green progress ring over four seconds, then cycle forward. Each detail is one concise paragraph with no hover state. Selecting any row closes the previous one and restarts the timer. Offscreen and hidden tabs stop the cycle. Home’s personal cards share one integrated Quick Ask surface; mobile users can switch between all five cards using tap buttons. The About photo fan lifts, tilts and pins individual photos with pointer, tap and keyboard controls. +Capabilities open the first row when visible, fill a bright green progress ring over four seconds, then cycle forward. Each detail is one concise paragraph with no hover state. Selecting any row closes the previous one and restarts the timer. Offscreen and hidden tabs stop the cycle. Home’s personal cards share one integrated Quick Ask surface; mobile shows four overlapping cards at once without horizontal scrolling. The About photo fan lifts, tilts and pins individual photos with pointer, tap and keyboard controls. Desktop navigation is fully expanded at the top (within 1px), collapses as soon as scrolling moves downward, and reopens when returning to the top. A focused navigation link returns focus to the menu button on collapse. Width transitions use a restrained 4px opening overshoot and 3px closing undershoot, settling over 600ms and 500ms respectively. The menu button reopens it, and mobile keeps its compact initial menu. Navigation width changes use a short spring-shaped transition. Button presses, tabs, expandable content and cards have restrained motion. Reduced-motion preferences disable animated transitions and momentum without removing control feedback. The compact wallet shares a single white chat surface; messages load for at least two seconds, remain for ten seconds, and close on an outside click. -Desktop wallet cards are 175px tall, their decorative top starts at 0px, and the wallet margin is 44px auto -20px. Mobile uses a compact 192px card deck with a 124px card over two pastel layers. The profile card opens first; five 44px tap targets switch between Work, Me, Study, Notes, and Code without scrolling. The active card retains its original destination and all of its text; inactive links are hidden from focus and assistive technology. The deck has 24px top and 12px bottom spacing, followed by Quick Ask. Card changes use a brief spring entrance, disabled for reduced motion. Desktop keeps the five-card fan, and resizing preserves the focused destination. Without JavaScript, all five links appear in a compact two-column grid with no horizontal scrolling. +Desktop keeps the five-card wallet fan with 175px cards and a 44px auto -20px margin. Mobile shows four pastel cards at once in two overlapping columns, with small rotations, raised coloured edges, and 148px cards. Quick Ask sits across the lower edge. There are no switch buttons or horizontal scrolling, and the layout works without JavaScript. Work, profile, education, and writing cards keep their original links. The GitHub card appears on desktop; the footer GitHub link remains available on mobile. The extra About call-to-action below Quick Ask is removed. + +The dark page canvas is #111113, measured from [Jeet's site](https://jeetcreates.cc/) on September 14, 2026. The saved-theme bootstrap and toolbar colour use the same value. The dark capabilities introduction uses the reference's 135-degree gradient from #454DAE to #5B4FC7 with #EAEAFF text and a soft highlighted icon tile. + +Article footers link to the original DEV publication using “View in”, the DEV badge, and an external arrow. The duplicate written-by sentence is removed; the author byline stays in the article header. The badge is from the [official DEV brand assets](https://dev.to/brand), stored at assets/icons/dev-badge.svg. The private-work detail ends at the confidentiality notice without the extra public-work button. diff --git a/editorial.css b/editorial.css index b2cfdfc..a8f591b 100644 --- a/editorial.css +++ b/editorial.css @@ -393,6 +393,16 @@ border: 0; padding-top: 40px; } +.article-credit .dev-publication { + min-height: 44px; + gap: 8px; +} +.dev-publication img { + width: 28px; + height: 32px; + background: #fff; + border-radius: 4px; +} .editorial-reader .next-reading { border: 0; background: var(--strong-glass); diff --git a/index.html b/index.html index 70d33db..b1126ca 100644 --- a/index.html +++ b/index.html @@ -7,7 +7,7 @@ - + @@ -28,13 +28,13 @@ - + - + - +

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file +

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/play-lab.html b/play-lab.html index 1a7ad16..41bf0be 100644 --- a/play-lab.html +++ b/play-lab.html @@ -7,7 +7,7 @@ - + @@ -28,13 +28,13 @@ - + - + - +

    Play Lab

    diff --git a/project.html b/project.html index 89f2b1c..505f637 100644 --- a/project.html +++ b/project.html @@ -7,7 +7,7 @@ - + @@ -28,12 +28,12 @@ - + - + - +

    SELECTED WORK

    A closer look at what
    I've built.

    12 projects

    01—06 / FEATURED PROJECTS

    07—12 / MORE WORK

    Project archive.

    PRIVATE FREELANCE WORK

    Behind the scenes.

    I have developed internal enterprise applications and business automation tools for private clients, supporting everyday operations, collaboration and documentation.

    Confidentiality notice. These applications were developed as part of private freelance and enterprise engagements. Source code, system architecture, client data and certain project details cannot be disclosed due to confidentiality requirements.

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/script.js b/script.js index b847aca..ba6f4b6 100644 --- a/script.js +++ b/script.js @@ -130,7 +130,7 @@ if (themeToggle) { themeToggle.setAttribute("aria-pressed", String(dark)); document .querySelector('meta[name="theme-color"]') - ?.setAttribute("content", dark ? "#18191c" : "#f5f4f0"); + ?.setAttribute("content", dark ? "#111113" : "#f5f4f0"); } // Adopt the theme already applied by the head script before first paint. applyTheme(document.documentElement.dataset.theme === "dark"); @@ -276,32 +276,7 @@ if (capabilityGroup) { const wallet = document.querySelector(".wallet"); if (wallet) { - const cards = [...wallet.querySelectorAll(".wallet-card")]; - const switcher = wallet.querySelector(".wallet-switcher"); - const choices = [...switcher.querySelectorAll("button")]; - let selected = "wallet-me"; - function showWalletCard(id = selected) { - selected = id; - cards.forEach((card) => { - card.hidden = smallScreen.matches && card.id !== selected; - }); - choices.forEach((button) => { - button.setAttribute("aria-pressed", String(button.getAttribute("aria-controls") === selected)); - }); - switcher.hidden = !smallScreen.matches; - } - choices.forEach((button) => { - button.addEventListener("click", () => showWalletCard(button.getAttribute("aria-controls"))); - }); - smallScreen.addEventListener("change", () => { - const focusedCard = cards.find((card) => card.contains(document.activeElement)); - const focusedChoice = switcher.contains(document.activeElement); - showWalletCard(focusedCard?.id || selected); - if (!smallScreen.matches && focusedChoice) document.getElementById(selected).focus(); - }); - wallet.dataset.walletReady = ""; - showWalletCard(); - cards.forEach((card) => { + wallet.querySelectorAll(".wallet-card").forEach((card) => { card.addEventListener("pointermove", (event) => { if (smallScreen.matches || reducedMotion.matches || event.pointerType === "touch") return; const bounds = card.getBoundingClientRect(); diff --git a/scripts/build.mjs b/scripts/build.mjs index cbe036e..bacb758 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -99,7 +99,7 @@ function metadata(options = {}) { ${options.noindex ? '' : ""} - + @@ -120,13 +120,13 @@ function metadata(options = {}) { - + ${options.page ? '' : ""} - + ${!options.page ? '' : ""} - + ${options.page ? '' : ""} `.replace( /\n[ \t]+\n/g, @@ -248,6 +248,7 @@ if (!check) { "profile/baivab-800.webp", "social-preview.png", "apple-touch-icon.png", + "icons/dev-badge.svg", "resume/Baivab_Sarkar_Resume.pdf", "resume/CV-BAIVAB SARKAR.pdf", ]) { diff --git a/scripts/dot-checks.mjs b/scripts/dot-checks.mjs index c8208a0..c730cae 100644 --- a/scripts/dot-checks.mjs +++ b/scripts/dot-checks.mjs @@ -145,12 +145,12 @@ export async function dotChecks(page, load) { })), }; }); - assert.equal(wallet.top, width < 768 ? "24px" : "44px", `Wallet top margin at ${width}px`); - assert.equal(wallet.bottom, width < 768 ? "12px" : "-20px", `Wallet spacing at ${width}px`); + assert.equal(wallet.top, width < 768 ? "28px" : "44px", `Wallet top margin at ${width}px`); + assert.equal(wallet.bottom, width < 768 ? "-14px" : "-20px", `Wallet spacing at ${width}px`); assert( wallet.cards.length === 5 && wallet.cards.every( - (card) => card.height === (width < 768 ? "124px" : "175px") && card.cap === "0px", + (card) => card.height === (width < 768 ? "148px" : "175px") && card.cap === (width < 768 ? "-6px" : "0px"), ), `All wallet cards use the requested dimensions at ${width}px`, ); diff --git a/scripts/home-sections.mjs b/scripts/home-sections.mjs index 643f577..a9cee47 100644 --- a/scripts/home-sections.mjs +++ b/scripts/home-sections.mjs @@ -2,13 +2,12 @@ export function renderCapabilities(caps, heading) { return `
    ${heading("FROM IDEA TO SOMETHING USEFUL", "Good ideas deserve
    thoughtful execution.", "capabilities-title")}

    I connect development with testing, so the things I build feel good to use and hold up behind the scenes.

    5 ways I put code to work

    ${caps.map(([title, desc, , detail], i) => `
    ${title}${desc}

    ${detail}

    `).join("")}
    `; } -export function renderHomeAbout(heading, quickAsk, link) { +export function renderHomeAbout(heading, quickAsk) { return `
    ${heading("THE HUMAN BEHIND THE CODE", "A little bit about me.", "about-title")}${quickAsk()}
    ${link("/about", "A little more about me", "button")}
    `; +
    ${quickAsk()}`; } diff --git a/scripts/site-pages.mjs b/scripts/site-pages.mjs index 7bdee62..4d50ece 100644 --- a/scripts/site-pages.mjs +++ b/scripts/site-pages.mjs @@ -87,7 +87,7 @@ export function renderSitePages(data, ui) { const editorNote = a.editorNote ? `` : ""; - const content = `
    ${toc(entries, "/blog", "Back to blog")}

    ${esc(articleCategory(a))}

    ${esc(a.title)}

    ${esc(a.summary)}

    ${articleArt(a, "article-hero-art")}${editorNote}
    ${a.bodyHtml || ""}

    Written by ${esc(profile.name)}.

    ${a.url ? link(a.url, "Publication history") : `Published on this website.`}
    ${next && next !== a ? `Continue reading${esc(next.title)}${arrow}` : ""}
    `; + const content = `
    ${toc(entries, "/blog", "Back to blog")}

    ${esc(articleCategory(a))}

    ${esc(a.title)}

    ${esc(a.summary)}

    ${articleArt(a, "article-hero-art")}${editorNote}
    ${a.bodyHtml || ""}
    ${a.url ? `View inDEV${arrow}` : `Published on this website.`}
    ${next && next !== a ? `Continue reading${esc(next.title)}${arrow}` : ""}
    `; pages[`blog/${a.slug}.html`] = shell(content, { path: a.localPath, title: `${a.title} — Baivab Sarkar`, @@ -131,7 +131,7 @@ export function renderSitePages(data, ui) { ["Focus", "Business automation"], ["Availability", "Confidential"], ], - )}
    ${enterprise.projects.map((p) => `
    ${p.id === "audit-management" ? '' : ""}

    ${p.label}

    ${p.title}

    ${p.summary}

    `).join("")}
    ${link("/work", "Explore public work", "button")}`, + )}
    ${enterprise.projects.map((p) => `
    ${p.id === "audit-management" ? '' : ""}

    ${p.label}

    ${p.title}

    ${p.summary}

    `).join("")}
    `, { path: "/work/enterprise", title: "Enterprise applications — Baivab Sarkar", diff --git a/scripts/templates.mjs b/scripts/templates.mjs index 9d2386f..d1fe8ef 100644 --- a/scripts/templates.mjs +++ b/scripts/templates.mjs @@ -123,7 +123,7 @@ export function renderPages(data, metadata) { .join( "", )}
    ${link("/blog", "All articles")}
    - ${renderHomeAbout(heading, renderQuickAsk, link)}`; + ${renderHomeAbout(heading, renderQuickAsk)}`; const shell = (content, options = {}) => ` ${metadata(options)}${options.playground ? '' : ""}${nav(options.page)}
    ${content}
    ${options.footer === false ? "" : footer(true)}
    `; diff --git a/scripts/theme-checks.mjs b/scripts/theme-checks.mjs index bd70ba9..e9b54ad 100644 --- a/scripts/theme-checks.mjs +++ b/scripts/theme-checks.mjs @@ -61,9 +61,9 @@ export async function themeChecks(browser, url) { `${route}: main script is still delayed`); const frames = await page.evaluate(() => window.themeFrames); assert(frames.every((frame) => frame.theme === "dark" && frame.scheme === "dark" - && frame.background === "rgb(24, 25, 28)"), + && frame.background === "rgb(17, 17, 19)"), `${route}: content is dark from the first rendered frame, before deferred initialization`); - assert.equal(await page.locator('meta[name="theme-color"]').getAttribute("content"), "#18191c"); + assert.equal(await page.locator('meta[name="theme-color"]').getAttribute("content"), "#111113"); } finally { release(); await page.waitForLoadState("networkidle"); diff --git a/scripts/verify.mjs b/scripts/verify.mjs index d878cbb..5beaffa 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -1475,7 +1475,7 @@ try { () => playground(page), ], ["cursor highlights and reduced motion", () => cursorDots(page)], - ["compact mobile wallet, card switching and no-scroll fallback", () => walletChecks(browser, preview.url)], + ["overlapping mobile wallet, links and no-scroll layout", () => walletChecks(browser, preview.url)], [ "tight dot masks, uniform edges, dark hover and compact wallet", () => dotChecks(page, load), diff --git a/scripts/wallet-checks.mjs b/scripts/wallet-checks.mjs index dce85e7..5407aa7 100644 --- a/scripts/wallet-checks.mjs +++ b/scripts/wallet-checks.mjs @@ -5,89 +5,67 @@ export async function walletChecks(browser, url) { try { const page = await context.newPage(); const errors = []; - page.on("pageerror", (error) => errors.push(error.message)); - const destinations = { - work: "/work/markdown-viewer", me: "/about", study: "/about#experience", - notes: "/blog", code: "https://github.com/ThisIs-Developer", - }; + page.on("pageerror", error => errors.push(error.message)); + const destinations = ["/work/markdown-viewer", "/about", "/about#experience", "/blog"]; for (const width of [320, 390, 440, 767]) { await page.setViewportSize({ width, height: 956 }); - await page.goto("about:blank"); await page.goto(`${url}/#about`, { waitUntil: "networkidle" }); - assert.equal(await page.locator(".wallet-card:visible").getAttribute("id"), "wallet-me"); for (const dark of [false, true]) { if (await page.locator(".theme-toggle").getAttribute("aria-pressed") !== String(dark)) await page.locator(".theme-toggle").dispatchEvent("click"); - for (const [choice, href] of Object.entries(destinations)) { - const button = page.locator(`[data-wallet-select="${choice}"]`); - await button.tap(); - await page.waitForTimeout(450); - const card = page.locator(".wallet-card:visible"); - assert.equal(await card.count(), 1, "Only the selected link is exposed"); - assert.equal(await card.getAttribute("href"), href, "Every destination remains available"); - assert.equal(await button.getAttribute("aria-pressed"), "true"); - assert.equal(await page.locator('.wallet-switcher [aria-pressed="true"]').count(), 1); - const layout = await page.locator(".wallet").evaluate((wallet) => { - const selected = wallet.querySelector(".wallet-card:not([hidden])"); - const cardBox = selected.getBoundingClientRect(); - const switcher = wallet.querySelector(".wallet-switcher").getBoundingClientRect(); - return { - scrolls: wallet.scrollWidth > wallet.clientWidth || wallet.scrollHeight > wallet.clientHeight, - overflow: document.documentElement.scrollWidth > innerWidth, - height: wallet.getBoundingClientRect().height, - inBounds: cardBox.left >= 0 && cardBox.right <= innerWidth, - clearControls: cardBox.bottom <= switcher.top - 4, - clearAsk: document.querySelector(".quick-ask").getBoundingClientRect().top >= switcher.bottom, - targets: [...wallet.querySelectorAll("button")].every((el) => el.clientHeight >= 44 && el.clientWidth >= 44), - readable: [...selected.querySelectorAll("small, strong, span:last-child")].every((el) => { - const box = el.getBoundingClientRect(); - return box.left >= cardBox.left && box.right <= cardBox.right - && box.top >= cardBox.top && box.bottom <= cardBox.bottom; - }), - }; - }); - assert.deepEqual(layout, { - scrolls: false, overflow: false, height: 192, inBounds: true, - clearControls: true, clearAsk: true, targets: true, readable: true, - }, `${width}px ${dark ? "dark" : "light"} ${choice}: compact, readable, and no scrolling`); - } + await page.locator(".wallet").scrollIntoViewIfNeeded(); + await page.waitForTimeout(300); + const cards = page.locator(".wallet-card:visible"); + assert.equal(await cards.count(), 4, "Four cards are visible together on mobile"); + assert.deepEqual(await cards.evaluateAll(links => links.map(link => link.getAttribute("href"))), destinations); + assert.equal(await page.locator(".wallet-switcher").count(), 0, "No card switching buttons"); + const layout = await page.locator(".wallet").evaluate(wallet => { + const cards = [...wallet.querySelectorAll(".wallet-card")].filter(el => el.getClientRects().length); + const boxes = cards.map(el => el.getBoundingClientRect()); + return { + noScroll: wallet.scrollWidth <= wallet.clientWidth && document.documentElement.scrollWidth <= innerWidth, + compact: wallet.getBoundingClientRect().height <= 310, + twoColumns: Math.abs(boxes[0].top - boxes[1].top) < 16 && Math.abs(boxes[2].top - boxes[3].top) < 16 && boxes[2].top > boxes[0].top + 90, + inBounds: boxes.every(box => box.left >= 0 && box.right <= innerWidth), + readable: cards.every(card => [...card.querySelectorAll("small, strong, span:last-child")].every(el => { + const box = el.getBoundingClientRect(); + const hit = document.elementFromPoint((box.left + box.right) / 2, (box.top + box.bottom) / 2); + return card.contains(hit); + })), + }; + }); + assert.deepEqual(layout, { noScroll: true, compact: true, twoColumns: true, inBounds: true, readable: true }, `${width}px ${dark ? "dark" : "light"}: readable overlapping cards with no scrolling`); } } + assert.equal(await page.getByRole("link", { name: "A little more about me", exact: true }).count(), 0); + assert(await page.locator('.social-links a[href="https://github.com/ThisIs-Developer"]').count(), "GitHub remains available in the footer"); await page.setViewportSize({ width: 440, height: 956 }); - await page.locator('[data-wallet-select="notes"]').focus(); - await page.keyboard.press("Space"); - assert.equal(await page.locator(".wallet-card:visible").getAttribute("id"), "wallet-notes"); - await page.emulateMedia({ reducedMotion: "reduce" }); - await page.locator('[data-wallet-select="work"]').tap(); - assert.equal(await page.locator(".wallet-card:visible").evaluate(el => getComputedStyle(el).animationName), "none"); - await page.locator(".wallet-card:visible").tap(); + await page.locator("#wallet-work").tap(); await page.waitForURL(`${url}/work/markdown-viewer`); - await page.goto(`${url}/#about`, { waitUntil: "networkidle" }); - await page.locator('[data-wallet-select="notes"]').focus(); - await page.keyboard.press("Space"); + await page.locator("#wallet-notes").focus(); + await page.keyboard.press("Enter"); + await page.waitForURL(`${url}/blog`); + await page.goto(`${url}/#about`, { waitUntil: "networkidle" }); await page.setViewportSize({ width: 1440, height: 1000 }); - assert.equal(await page.locator(".wallet-card:visible").count(), 5, "Desktop keeps its full fan"); - assert.equal(await page.locator(".wallet-switcher").isVisible(), false); - assert.equal(await page.evaluate(() => document.activeElement.id), "wallet-notes", "Resize keeps focus on the selected destination"); - await page.locator("#wallet-code").focus(); + assert.equal(await page.locator(".wallet-card:visible").count(), 5, "Desktop keeps its five-card fan"); await page.setViewportSize({ width: 390, height: 956 }); - assert.equal(await page.locator(".wallet-card:visible").getAttribute("id"), "wallet-code", "Focused desktop card stays available after resizing"); + assert.equal(await page.locator(".wallet-card:visible").count(), 4); + await page.emulateMedia({ reducedMotion: "reduce" }); + assert(await page.locator("#wallet-work").evaluate(el => parseFloat(getComputedStyle(el).transitionDuration) <= 0.001), "Reduced motion suppresses the card transition"); await page.locator("#quick-question").fill("hello"); await page.locator("#quick-question").press("Enter"); await page.locator('#ask-answer[data-state="ready"]').waitFor(); - assert.deepEqual(errors, [], "Wallet switching does not cause browser errors"); + assert.deepEqual(errors, [], "No wallet-related browser errors"); } finally { await context.close(); } - const noJs = await browser.newContext({ javaScriptEnabled: false, viewport: { width: 320, height: 956 } }); try { const page = await noJs.newPage(); await page.goto(`${url}/#about`); - assert.equal(await page.locator(".wallet-card:visible").count(), 5, "All links are available without JavaScript"); - assert.equal(await page.locator(".wallet-switcher").isVisible(), false); - assert(await page.locator(".wallet").evaluate(el => el.scrollWidth <= el.clientWidth), "The fallback also avoids horizontal scrolling"); + assert.equal(await page.locator(".wallet-card:visible").count(), 4, "Mobile cards work without JavaScript"); + assert(await page.locator(".wallet").evaluate(el => el.scrollWidth <= el.clientWidth)); } finally { await noJs.close(); } diff --git a/style.css b/style.css index 3c11617..ed4a932 100644 --- a/style.css +++ b/style.css @@ -39,7 +39,7 @@ color-scheme: light; } :root[data-theme="dark"] { - --paper: #18191c; + --paper: #111113; --ink: #f3f2ed; --muted: #aaaab3; --blue: #a0afff; @@ -698,6 +698,18 @@ input { font-size: 14px; line-height: 1.6; } +@media screen { + :root[data-theme="dark"] .capability-intro { + background: linear-gradient(135deg, #454dae, #5b4fc7); + color: #eaeaff; + box-shadow: inset 0 1px 0 #ffffff26, 0 6px 18px #00000014; + } + :root[data-theme="dark"] .capability-intro > span { + background: linear-gradient(145deg, #c8cbff, #7b72d4); + color: #30256b; + box-shadow: inset 0 1px 2px #ffffffb3, 0 3px 6px #1c174d66; + } +} .capabilities { padding: 15px 13px; background: color-mix(in srgb, var(--ink) 4%, var(--paper)); @@ -2642,160 +2654,66 @@ input { width: 38px; height: 38px; } + /* Four overlapping personal cards sit directly above Quick Ask. */ .wallet { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - align-items: stretch; - gap: 12px; + align-items: start; + gap: 0; max-width: 420px; overflow: visible; - padding: 0; - margin: 24px auto 12px; + padding: 12px 8px 0; + margin: 28px auto -14px; width: 100%; } .wallet-card { width: 100%; - height: 175px; min-width: 0; - padding: 12px; + height: 148px; + padding: 10px 12px; margin: 0; - transform: none; + border-radius: 18px; + transform: rotate(var(--wallet-angle)); + } + .wallet .wallet-card::before { + top: -6px; + height: 24px; + clip-path: none; + border-radius: 18px 18px 0 0; } .wallet-card small { - font-size: 11px; + font-size: 10px; + line-height: 1.2; + margin-bottom: 6px; } .wallet-card strong { - font-size: 16px; + margin-top: 6px; + font-size: 14px; + line-height: 1.2; } .wallet-card > span:last-child { - font-size: 12px; - } - .wallet-icon { - width: 48px; - height: 48px; + margin-top: 3px; + font-size: 10px; + line-height: 1.3; } + .wallet .wallet-icon, + .wallet .wallet-card img { + width: 34px; + height: 36px; + border-radius: 9px; + font-size: 20px; + } + .wallet-icon svg { width: 23px; height: 23px; } + .wallet-purple { --wallet-angle: -4deg; z-index: 1; } + .wallet-yellow { --wallet-angle: 3deg; z-index: 2; } + .wallet-green { --wallet-angle: -3deg; z-index: 3; } + .wallet-pink { --wallet-angle: 3deg; z-index: 4; } + .wallet-green, + .wallet-pink { margin-top: -12px; } + .wallet-blue { display: none; } .wallet-card:is(:hover, :focus-visible) { - transform: translateY(-3px); - } - /* A pocket-sized deck: every destination is one tap away, without a scroller. */ - .wallet[data-wallet-ready] { - display: block; - position: relative; - height: 192px; - isolation: isolate; + transform: translateY(-4px) rotate(0deg); } - .wallet[data-wallet-ready]::before, - .wallet[data-wallet-ready]::after { - content: ""; - position: absolute; - inset: 10px 18px auto; - height: 124px; - border-radius: 20px; - pointer-events: none; - background: #c9b4ed; - transform: rotate(-4deg) translateY(-4px); - box-shadow: inset 0 1px 0 #ffffff70; - z-index: -2; - } - .wallet[data-wallet-ready]::after { - background: #c5dda2; - transform: rotate(3deg) translateY(-1px); - z-index: -1; - } - .wallet[data-wallet-ready] .wallet-card { - position: absolute; - top: 12px; - left: 8px; - width: calc(100% - 16px); - height: 124px; - padding: 14px 16px; - display: grid; - grid-template-columns: 54px minmax(0, 1fr); - grid-template-rows: auto auto auto; - align-content: center; - column-gap: 14px; - row-gap: 4px; - border-radius: 20px; - transform: none; - animation: wallet-reveal 420ms var(--spring); - } - .wallet[data-wallet-ready] .wallet-card::after { - content: "↗"; - position: absolute; - top: 12px; - right: 14px; - font-size: 16px; - line-height: 1; - opacity: 0.6; - } - .wallet[data-wallet-ready] .wallet-card small { - grid-column: 2; - grid-row: 1; - margin: 0; - font-size: 11px; - } - .wallet[data-wallet-ready] :is(.wallet-icon, .wallet-card img) { - grid-column: 1; - grid-row: 1 / 4; - align-self: center; - width: 54px; - height: 58px; - border-radius: 14px; - } - .wallet[data-wallet-ready] .wallet-card strong { - grid-column: 2; - grid-row: 2; - margin: 0; - font-size: clamp(18px, 4.7vw, 22px); - line-height: 1.15; - overflow-wrap: anywhere; - } - .wallet[data-wallet-ready] .wallet-card > span:last-child { - grid-column: 2; - grid-row: 3; - margin: 0; - font-size: 12px; - } - .wallet[data-wallet-ready] .wallet-card:is(:hover, :focus-visible) { - transform: translateY(-3px); - } - .wallet-switcher { - position: absolute; - inset: auto 0 0; - display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); - gap: 4px; - z-index: 6; - } - .wallet-switcher button { - --wallet-choice: #dccbf4; - min-width: 0; - min-height: 44px; - padding: 6px 2px; - border: 0; - border-radius: 15px; - color: var(--muted); - background: var(--glass); - font-size: 11px; - font-weight: 600; - transition: background 200ms, color 200ms, transform 200ms var(--out); - } - .wallet-switcher [data-wallet-select="me"] { --wallet-choice: #f4dda0; } - .wallet-switcher [data-wallet-select="study"] { --wallet-choice: #c5dda2; } - .wallet-switcher [data-wallet-select="notes"] { --wallet-choice: #f1bdd2; } - .wallet-switcher [data-wallet-select="code"] { --wallet-choice: #bfd5f1; } - .wallet-switcher button[aria-pressed="true"] { - color: #302743; - background: var(--wallet-choice); - box-shadow: inset 0 1px 0 #ffffff75; - } - .wallet-switcher button:active { - transform: scale(0.96); - } -} -@keyframes wallet-reveal { - from { opacity: 0; transform: translateY(8px) rotate(-2deg); } - to { opacity: 1; transform: none; } } @media (prefers-reduced-motion: reduce) { .capability[open] .capability-detail { @@ -2808,15 +2726,6 @@ input { .wallet-card:is(:hover, :focus-visible) { transform: translateY(-8px); } - .wallet[data-wallet-ready] .wallet-card, - .wallet-switcher button { - animation: none; - transition: none; - } - .wallet[data-wallet-ready] .wallet-card:is(:hover, :focus-visible), - .wallet-switcher button:active { - transform: none; - } } /* Small labels stay readable at every breakpoint. */ .eyebrow, diff --git a/theme.js b/theme.js index 481ddff..ab0dd4c 100644 --- a/theme.js +++ b/theme.js @@ -9,5 +9,5 @@ document.documentElement.dataset.theme = dark ? "dark" : "light"; document .querySelector('meta[name="theme-color"]') - ?.setAttribute("content", dark ? "#18191c" : "#f5f4f0"); + ?.setAttribute("content", dark ? "#111113" : "#f5f4f0"); })(); diff --git a/work.html b/work.html index 89f2b1c..505f637 100644 --- a/work.html +++ b/work.html @@ -7,7 +7,7 @@ - + @@ -28,12 +28,12 @@ - + - + - +

    SELECTED WORK

    A closer look at what
    I've built.

    12 projects

    01—06 / FEATURED PROJECTS

    07—12 / MORE WORK

    Project archive.

    PRIVATE FREELANCE WORK

    Behind the scenes.

    I have developed internal enterprise applications and business automation tools for private clients, supporting everyday operations, collaboration and documentation.

    Confidentiality notice. These applications were developed as part of private freelance and enterprise engagements. Source code, system architecture, client data and certain project details cannot be disclosed due to confidentiality requirements.

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/work/ams.html b/work/ams.html index 42828d9..eca5873 100644 --- a/work/ams.html +++ b/work/ams.html @@ -7,7 +7,7 @@ - + @@ -28,12 +28,12 @@ - + - + - +

    Full-stack · Team project

    AMS

    Role-based attendance workflows for administrators, teachers and students.

    Project
    Team prototype
    Period
    2024
    Built with
    JavaScript, Spring, Redis
    Academic Management System attendance dashboard on two mobile screens.

    Overview

    Role-based attendance workflows for administrators, teachers and students.

    Contributed to a five-person academic management project.

    The problem

    Academic attendance records need coordinated access and reporting across institution roles.

    My contribution

    Contributed to a five-person academic management project.

    JavaScriptSpringRedisPostgreSQLJPA

    Inside the build

    • Role-specific attendance interfaces.
    • Attendance recording and report workflows.

    Project notes

    Team prototype

    AMS is part of my work in full-stack · team project. The links above provide the available project and implementation details.

    Next projectNoteMarker Extension

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/work/body-language.html b/work/body-language.html index 6e00da9..8509b48 100644 --- a/work/body-language.html +++ b/work/body-language.html @@ -7,7 +7,7 @@ - + @@ -28,12 +28,12 @@ - + - + - +

    Computer vision · Notebook

    Body Language Detection

    A computer-vision experiment comparing landmark-based classification pipelines.

    Project
    Archived research experiment
    Period
    2023
    Built with
    Python, MediaPipe, OpenCV
    Bar chart of labeled samples from the Body Language Detection experiment.

    Overview

    A computer-vision experiment comparing landmark-based classification pipelines.

    Built notebooks for collecting labeled landmark data, training classifiers and exporting models.

    The problem

    Explore how video landmarks can be collected and used as classifier inputs.

    My contribution

    Built notebooks for collecting labeled landmark data, training classifiers and exporting models.

    PythonMediaPipeOpenCVscikit-learnTensorFlow

    Inside the build

    • Collect labeled landmark data from webcam or recorded video.
    • Compare scikit-learn pipelines and a TensorFlow Lite export.

    Project notes

    Archived research experiment

    Body Language Detection is part of my work in computer vision · notebook. The links above provide the available project and implementation details.

    Next projectMediChain

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/work/csv-chatbot.html b/work/csv-chatbot.html index e9e8a5d..9cdc1f4 100644 --- a/work/csv-chatbot.html +++ b/work/csv-chatbot.html @@ -7,7 +7,7 @@ - + @@ -28,12 +28,12 @@ - + - + - +

    AI · Retrieval experiment

    Llama 2 GGML CSV Chatbot

    Ask questions of uploaded CSV data using local model inference and vector retrieval.

    Project
    Research prototype
    Period
    2024
    Built with
    Python, LangChain, Streamlit
    Llama 2 GGML CSV Chatbot — replaceable project image
    Project image placeholder

    Overview

    Ask questions of uploaded CSV data using local model inference and vector retrieval.

    Built a Streamlit prototype combining CSV ingestion, embedding search and a quantized Llama 2 model.

    The problem

    Exploring tabular data through natural language requires grounding responses in the uploaded file.

    My contribution

    Built a Streamlit prototype combining CSV ingestion, embedding search and a quantized Llama 2 model.

    PythonLangChainStreamlitLlama 2FAISS

    Inside the build

    • Chunk CSV data and index it with sentence-transformer embeddings and FAISS.
    • Retrieve relevant rows for a Llama 2 question-answering workflow.

    Project notes

    Research prototype

    Llama 2 GGML CSV Chatbot is part of my work in ai · retrieval experiment. The links above provide the available project and implementation details.

    Next projectNews Scraping Platform

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/work/enterprise.html b/work/enterprise.html index b20c7f3..58e7b47 100644 --- a/work/enterprise.html +++ b/work/enterprise.html @@ -7,7 +7,7 @@ - + @@ -28,12 +28,12 @@ - + - + - + -

    Private freelance work

    Enterprise applications.

    I have developed internal enterprise applications and business automation tools for private clients, supporting everyday operations, collaboration and documentation.

    Engagement
    Private freelance
    Focus
    Business automation
    Availability
    Confidential

    Finance workflows

    Enterprise Invoice Management System

    An internal application for managing company invoices, records and related business workflows.

    Team workflows

    Team Collaboration & Workflow Platform

    An internal collaboration application designed to improve communication, task coordination and team productivity.

    Document automation

    Proposal Document Generator

    An automated system for generating standardized business proposals and formatted project documentation.

    Audit workflows

    Audit Management & Compliance System

    Internal software supporting audit and compliance workflows, documentation, tracking and reporting, including contributions to audit and project environments associated with NTPC, Bluestar and other enterprise clients.

    Explore public work

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file +

    Private freelance work

    Enterprise applications.

    I have developed internal enterprise applications and business automation tools for private clients, supporting everyday operations, collaboration and documentation.

    Engagement
    Private freelance
    Focus
    Business automation
    Availability
    Confidential

    Finance workflows

    Enterprise Invoice Management System

    An internal application for managing company invoices, records and related business workflows.

    Team workflows

    Team Collaboration & Workflow Platform

    An internal collaboration application designed to improve communication, task coordination and team productivity.

    Document automation

    Proposal Document Generator

    An automated system for generating standardized business proposals and formatted project documentation.

    Audit workflows

    Audit Management & Compliance System

    Internal software supporting audit and compliance workflows, documentation, tracking and reporting, including contributions to audit and project environments associated with NTPC, Bluestar and other enterprise clients.

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/work/markdown-viewer.html b/work/markdown-viewer.html index 25a70de..d6eefa4 100644 --- a/work/markdown-viewer.html +++ b/work/markdown-viewer.html @@ -7,7 +7,7 @@ - + @@ -28,12 +28,12 @@ - + - + - +

    Developer tools · Open source

    Markdown Viewer

    A local-first Markdown workspace, from the first draft to the shared document.

    Project
    Actively maintained
    Period
    2024–2026
    Built with
    JavaScript, IndexedDB, Cloudflare
    Markdown Viewer workspace showing its document explorer, editor and rendered preview.

    Overview

    A local-first Markdown workspace, from the first draft to the shared document.

    Created and maintain the editor, its document workspace, rendering workflows and web/desktop delivery.

    The problem

    Technical writing needs more than a preview: documents, diagrams, review and sharing should work together.

    My contribution

    Created and maintain the editor, its document workspace, rendering workflows and web/desktop delivery.

    JavaScriptIndexedDBCloudflareNeutralinojsPlaywright

    Inside the build

    • Folders, tabs, IndexedDB autosave and encrypted workspace storage.
    • Rich rendering, anchored review comments and optional Live Share.

    Project notes

    Actively maintained

    Markdown Viewer is part of my work in developer tools · open source. The links above provide the available project and implementation details.

    Next projectSei-Sangeet-Bangla

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/work/medical-chatbot.html b/work/medical-chatbot.html index 85f3ff6..0362963 100644 --- a/work/medical-chatbot.html +++ b/work/medical-chatbot.html @@ -7,7 +7,7 @@ - + @@ -28,12 +28,12 @@ - + - + - +

    AI · Retrieval experiment

    Llama 2 GGML Medical Chatbot

    An experiment in answering questions from medical reference documents with a quantized language model.

    Project
    Research prototype
    Period
    2023–2024
    Built with
    Python, Llama 2, LangChain
    Llama 2 medical document retrieval prototype interface

    Overview

    An experiment in answering questions from medical reference documents with a quantized language model.

    Built the retrieval workflow, model integration and chatbot interface using Python and LangChain.

    The problem

    Explore document-grounded question answering using local model inference and retrieved source passages.

    My contribution

    Built the retrieval workflow, model integration and chatbot interface using Python and LangChain.

    PythonLlama 2LangChainFAISSChainlit

    Inside the build

    • Retrieve document passages using sentence-transformer embeddings and FAISS.
    • Pass retrieved context to a pretrained Llama 2 model and return source documents with the answer.

    Project notes

    Research prototype

    This is a document-retrieval experiment, not a clinical system or a substitute for professional medical advice. It uses a pretrained model; no model-training or clinical-accuracy claim is made.

    Next projectBody Language Detection

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/work/medichain.html b/work/medichain.html index e78b6dd..1c8f587 100644 --- a/work/medichain.html +++ b/work/medichain.html @@ -7,7 +7,7 @@ - + @@ -28,12 +28,12 @@ - + - + - +

    Full stack · Blockchain prototype

    MediChain

    Making a medicine's journey visible across a pharmaceutical supply chain.

    Project
    Team prototype
    Period
    2024–2025
    Built with
    JavaScript, Solidity, Ethereum
    MediChain dashboard showing medicine stock and supply-chain records.

    Overview

    Making a medicine's journey visible across a pharmaceutical supply chain.

    Led full-stack development in a four-person team, connecting the web interface with blockchain workflows.

    The problem

    Medicine registration, inventory and purchase history need a shared record across supply-chain roles.

    My contribution

    Led full-stack development in a four-person team, connecting the web interface with blockchain workflows.

    JavaScriptSolidityEthereumWeb3.jsMetaMask

    Inside the build

    • Track medicine batches, lifecycle states and purchase history.
    • Connect role-based web interfaces to Ethereum contracts.

    Project notes

    Team prototype

    MediChain is part of my work in full stack · blockchain prototype. The links above provide the available project and implementation details.

    Next projectAMS

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/work/news-scraper.html b/work/news-scraper.html index c84aa52..a9d804a 100644 --- a/work/news-scraper.html +++ b/work/news-scraper.html @@ -7,7 +7,7 @@ - + @@ -28,12 +28,12 @@ - + - + - +

    Automation · Python

    News Scraping Platform

    A Django experiment for collecting news articles from multiple sources.

    Project
    Archived experiment
    Period
    2023–2024
    Built with
    Python, Django, Selenium
    News Scraping Platform — replaceable project image
    Project image placeholder

    Overview

    A Django experiment for collecting news articles from multiple sources.

    Built scraping workflows with BeautifulSoup and Selenium, integrated into Django.

    The problem

    Repeated article collection can be automated into a consistent data workflow.

    My contribution

    Built scraping workflows with BeautifulSoup and Selenium, integrated into Django.

    PythonDjangoSeleniumBeautifulSoupMySQL

    Inside the build

    • Concurrent collection from multiple publications.
    • Export collected records to Excel or MySQL.

    Project notes

    Archived experiment

    News Scraping Platform is part of my work in automation · python. The links above provide the available project and implementation details.

    Next projectTaskFlow

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/work/notemarker.html b/work/notemarker.html index 4cf9996..92944d6 100644 --- a/work/notemarker.html +++ b/work/notemarker.html @@ -7,7 +7,7 @@ - + @@ -28,12 +28,12 @@ - + - + - +

    Browser extension · Productivity

    NoteMarker Extension

    Keep the useful parts of the web, right where you found them.

    Project
    Released for Firefox & Chromium
    Period
    2024
    Built with
    JavaScript, WebExtensions, Browser Storage
    NoteMarker highlights and sticky notes on a research paper webpage.

    Overview

    Keep the useful parts of the web, right where you found them.

    Built and maintain an open-source extension for highlighting webpages and creating persistent notes.

    The problem

    Research gets fragmented when highlights and notes are separated from the pages that inspired them.

    My contribution

    Built and maintain an open-source extension for highlighting webpages and creating persistent notes.

    JavaScriptWebExtensionsBrowser StorageHTML & CSS

    Inside the build

    • Persistent five-color highlights and rich-text sticky notes.
    • Move, resize and manage annotations on the original webpage.

    Project notes

    Released for Firefox & Chromium

    NoteMarker Extension is part of my work in browser extension · productivity. The links above provide the available project and implementation details.

    Next projectSketchFlow

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/work/sei-sangeet-bangla.html b/work/sei-sangeet-bangla.html index f4bf430..c57bc66 100644 --- a/work/sei-sangeet-bangla.html +++ b/work/sei-sangeet-bangla.html @@ -7,7 +7,7 @@ - + @@ -28,12 +28,12 @@ - + - + - +

    Web app · Music

    Sei-Sangeet-Bangla

    A Bengali music experience set inside an illustrated room, with a song list and a familiar player.

    Project
    Private source
    Period
    2026
    Built with
    Web development
    Sei-Sangeet-Bangla music room with Bengali song list and player

    Overview

    A Bengali music experience set inside an illustrated room, with a song list and a familiar player.

    Developed Sei-Sangeet-Bangla as a personal music project.

    The problem

    Create a dedicated web experience around Bengali music.

    My contribution

    Developed Sei-Sangeet-Bangla as a personal music project.

    Web development

    Inside the build

    • Browse the song list inside a nostalgic, illustrated Bengali room.
    • Use track, playback, seek and volume controls in the music player.

    Project notes

    Private source

    The project is included in my portfolio, while its source repository remains private.

    Next projectLlama 2 GGML Medical Chatbot

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/work/simon.html b/work/simon.html index b0d0037..0851cdb 100644 --- a/work/simon.html +++ b/work/simon.html @@ -7,7 +7,7 @@ - + @@ -28,12 +28,12 @@ - + - + - +

    Web app · Memory game

    Simon

    A browser memory game built around color, sound and an increasingly long sequence to remember.

    Project
    Open-source game
    Period
    2024
    Built with
    JavaScript, HTML & CSS, localStorage
    Simon — replaceable project image
    Project image placeholder

    Overview

    A browser memory game built around color, sound and an increasingly long sequence to remember.

    Built the game interface, randomized sequence logic, audio feedback and local high-score tracking in JavaScript.

    The problem

    Recreate the classic Simon memory challenge with immediate visual feedback and simple browser controls.

    My contribution

    Built the game interface, randomized sequence logic, audio feedback and local high-score tracking in JavaScript.

    JavaScriptHTML & CSSlocalStorage

    Inside the build

    • Follow flashing colors and repeat the growing sequence to advance through levels.
    • Hear feedback on button presses and mistakes, restart a round and keep the highest score in the current browser.

    Project notes

    Open-source game

    The high score is saved locally in the current browser. The game is a personal implementation of the classic Simon memory challenge.

    Next projectMarkdown Viewer

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/work/sketchflow.html b/work/sketchflow.html index 46bdb83..d4386e7 100644 --- a/work/sketchflow.html +++ b/work/sketchflow.html @@ -7,7 +7,7 @@ - + @@ -28,12 +28,12 @@ - + - + - +

    Canvas · Creative tool

    SketchFlow

    A browser canvas for sketching ideas, diagrams and freehand drawings.

    Project
    Open-source experiment
    Period
    2024–2026
    Built with
    JavaScript, Canvas API, HTML & CSS
    SketchFlow drawing canvas with a colorful circular sketch.

    Overview

    A browser canvas for sketching ideas, diagrams and freehand drawings.

    Built the canvas interaction, shape tools and drawing controls in vanilla JavaScript.

    The problem

    Quick visual thinking benefits from a lightweight drawing workspace.

    My contribution

    Built the canvas interaction, shape tools and drawing controls in vanilla JavaScript.

    JavaScriptCanvas APIHTML & CSS

    Inside the build

    • Draw shapes, arrows, text and freehand strokes.
    • Adjust styling and zoom; export PNG or save JSON.

    Project notes

    Open-source experiment

    SketchFlow is part of my work in canvas · creative tool. The links above provide the available project and implementation details.

    Next projectLlama 2 GGML CSV Chatbot

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/work/taskflow.html b/work/taskflow.html index 91e7e5f..2984495 100644 --- a/work/taskflow.html +++ b/work/taskflow.html @@ -7,7 +7,7 @@ - + @@ -28,12 +28,12 @@ - + - + - +

    Developer tools · Productivity

    TaskFlow

    A flexible task board for organizing work, one column and one card at a time.

    Project
    Open-source tool
    Period
    2024–2025
    Built with
    JavaScript, HTML & CSS, Bootstrap
    TaskFlow task cards arranged in To Do, In Progress and Done columns

    Overview

    A flexible task board for organizing work, one column and one card at a time.

    Built a browser-based task management interface with customizable columns and drag-and-drop task cards.

    The problem

    Keep tasks, priorities and supporting details together in a lightweight visual workflow.

    My contribution

    Built a browser-based task management interface with customizable columns and drag-and-drop task cards.

    JavaScriptHTML & CSSBootstraplocalStorage

    Inside the build

    • Organize tasks with due dates, priorities, assignees, tags, comments and checklists.
    • Search tasks, move cards between columns and save the board in browser localStorage.

    Project notes

    Open-source tool

    The board is stored in the current browser. Assignee fields help organize work; they do not imply a hosted multi-user collaboration backend.

    Next projectSimon

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file From 49c9974cfbbfc4a9581b278288902f3ce3042e90 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Mon, 14 Sep 2026 17:50:47 +0530 Subject: [PATCH 20/22] fix(blog): correct DEV logo proportions and remove white bands --- 404.html | 2 +- about.html | 2 +- assets/icons/dev-badge.svg | 2 +- blog.html | 2 +- ...s-in-your-browser-your-desktop-and-a-single-url-488m.html | 4 ++-- ...comprehensive-guide-to-development-and-features-4gpf.html | 4 ++-- blog/do-you-know-llama--3c2d.html | 4 ++-- blog/epic-games-membership-on-github-3ml.html | 4 ++-- blog/introducing-markdown-viewer-v20-com.html | 4 ++-- blog/llama-2-ggml-medical-chatbot-5043.html | 4 ++-- blog/opencvthe-enchanted-palette-part-2-424k.html | 4 ++-- blog/opencvunleashing-the-magic-of-images-1kdb.html | 4 ++-- blog/portfolio-thisis-developer-56bf.html | 4 ++-- blog/record-and-process-audio-with-python-2a3b.html | 4 ++-- blog/secure-your-github-commits-with-verification-3hja.html | 4 ++-- editorial.css | 5 ++--- index.html | 2 +- play-lab.html | 2 +- project.html | 2 +- scripts/build.mjs | 2 +- scripts/site-pages.mjs | 2 +- work.html | 2 +- work/ams.html | 2 +- work/body-language.html | 2 +- work/csv-chatbot.html | 2 +- work/enterprise.html | 2 +- work/markdown-viewer.html | 2 +- work/medical-chatbot.html | 2 +- work/medichain.html | 2 +- work/news-scraper.html | 2 +- work/notemarker.html | 2 +- work/sei-sangeet-bangla.html | 2 +- work/simon.html | 2 +- work/sketchflow.html | 2 +- work/taskflow.html | 2 +- 35 files changed, 47 insertions(+), 48 deletions(-) diff --git a/404.html b/404.html index 9924f9c..23225aa 100644 --- a/404.html +++ b/404.html @@ -31,7 +31,7 @@ - + diff --git a/about.html b/about.html index e6a21a0..631e721 100644 --- a/about.html +++ b/about.html @@ -31,7 +31,7 @@ - + diff --git a/assets/icons/dev-badge.svg b/assets/icons/dev-badge.svg index 9fa42ff..63fc1ee 100644 --- a/assets/icons/dev-badge.svg +++ b/assets/icons/dev-badge.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/blog.html b/blog.html index c3b28ce..38d6afa 100644 --- a/blog.html +++ b/blog.html @@ -31,7 +31,7 @@ - + diff --git a/blog/a-markdown-editor-that-lives-in-your-browser-your-desktop-and-a-single-url-488m.html b/blog/a-markdown-editor-that-lives-in-your-browser-your-desktop-and-a-single-url-488m.html index ef3b3d7..5c4b095 100644 --- a/blog/a-markdown-editor-that-lives-in-your-browser-your-desktop-and-a-single-url-488m.html +++ b/blog/a-markdown-editor-that-lives-in-your-browser-your-desktop-and-a-single-url-488m.html @@ -31,7 +31,7 @@ - + @@ -519,4 +519,4 @@

    What's Next?

    -
    View inDEV
    Continue readingInside Markdown Viewer 2.0

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file +
    View inDEV
    Continue readingInside Markdown Viewer 2.0

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/blog/building-a-markdown-viewer-web-application-a-comprehensive-guide-to-development-and-features-4gpf.html b/blog/building-a-markdown-viewer-web-application-a-comprehensive-guide-to-development-and-features-4gpf.html index 8e041d3..fefbba8 100644 --- a/blog/building-a-markdown-viewer-web-application-a-comprehensive-guide-to-development-and-features-4gpf.html +++ b/blog/building-a-markdown-viewer-web-application-a-comprehensive-guide-to-development-and-features-4gpf.html @@ -31,7 +31,7 @@ - + @@ -197,4 +197,4 @@

    Quick Setup Guide:

    -

    Image description

    View inDEV
    Continue readingQuestion answering over CSV data with Llama 2

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file +

    Image description

    View inDEV
    Continue readingQuestion answering over CSV data with Llama 2

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/blog/do-you-know-llama--3c2d.html b/blog/do-you-know-llama--3c2d.html index 0320716..6147d90 100644 --- a/blog/do-you-know-llama--3c2d.html +++ b/blog/do-you-know-llama--3c2d.html @@ -31,7 +31,7 @@ - + @@ -192,4 +192,4 @@

    Important Notes

  • While robust, this chatbot is not a substitute for professional advice.
  • Ensure the CSV file adheres to the expected format for optimal performance.
  • -
    View inDEV
    Continue readingBuilding a document-retrieval prototype with Llama 2

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file +
    View inDEV
    Continue readingBuilding a document-retrieval prototype with Llama 2

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/blog/epic-games-membership-on-github-3ml.html b/blog/epic-games-membership-on-github-3ml.html index f17e9e9..c7975a0 100644 --- a/blog/epic-games-membership-on-github-3ml.html +++ b/blog/epic-games-membership-on-github-3ml.html @@ -31,7 +31,7 @@ - + @@ -175,4 +175,4 @@

    A Word of Caution: Protect Y -

    Success Gif

    View inDEV
    Continue readingSigning Git commits with GPG

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file +

    Success Gif

    View inDEV
    Continue readingSigning Git commits with GPG

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/blog/introducing-markdown-viewer-v20-com.html b/blog/introducing-markdown-viewer-v20-com.html index 5004c30..d0e2422 100644 --- a/blog/introducing-markdown-viewer-v20-com.html +++ b/blog/introducing-markdown-viewer-v20-com.html @@ -31,7 +31,7 @@ - + @@ -289,4 +289,4 @@

    Final Thoughts

    -
    View inDEV
    Continue readingManaging public membership in large GitHub organizations

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file +
    View inDEV
    Continue readingManaging public membership in large GitHub organizations

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/blog/llama-2-ggml-medical-chatbot-5043.html b/blog/llama-2-ggml-medical-chatbot-5043.html index 762f2db..7636591 100644 --- a/blog/llama-2-ggml-medical-chatbot-5043.html +++ b/blog/llama-2-ggml-medical-chatbot-5043.html @@ -31,7 +31,7 @@ - + @@ -119,4 +119,4 @@

    PDF

    Conversation examples img-3.pdf

    Image description

    Important Notes

    -
    • While powerful, this chatbot isn't a substitute for professional medical advice.
    View inDEV
    Continue readingImage processing with OpenCV: filters, contours and histograms

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file +
    • While powerful, this chatbot isn't a substitute for professional medical advice.
    View inDEV
    Continue readingImage processing with OpenCV: filters, contours and histograms

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/blog/opencvthe-enchanted-palette-part-2-424k.html b/blog/opencvthe-enchanted-palette-part-2-424k.html index c9cec86..234468e 100644 --- a/blog/opencvthe-enchanted-palette-part-2-424k.html +++ b/blog/opencvthe-enchanted-palette-part-2-424k.html @@ -31,7 +31,7 @@ - + @@ -104,4 +104,4 @@

    Analyze an image using Histogr -

    Image description

    View inDEV
    Continue readingGetting started with images in OpenCV

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file +

    Image description

    View inDEV
    Continue readingGetting started with images in OpenCV

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/blog/opencvunleashing-the-magic-of-images-1kdb.html b/blog/opencvunleashing-the-magic-of-images-1kdb.html index 2d5d369..456836c 100644 --- a/blog/opencvunleashing-the-magic-of-images-1kdb.html +++ b/blog/opencvunleashing-the-magic-of-images-1kdb.html @@ -31,7 +31,7 @@ - + @@ -52,4 +52,4 @@

    Image description
    Image description
    -Image description

    View inDEV
    Continue readingRecording and analysing audio with Python

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file +Image description

    View inDEV
    Continue readingRecording and analysing audio with Python

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/blog/portfolio-thisis-developer-56bf.html b/blog/portfolio-thisis-developer-56bf.html index 795f620..c567265 100644 --- a/blog/portfolio-thisis-developer-56bf.html +++ b/blog/portfolio-thisis-developer-56bf.html @@ -31,7 +31,7 @@ - + @@ -113,4 +113,4 @@

    After sending Message.

    Drop a Comment to Show Your Support and Connect. Let's Make Learning and Creating Together! #ShareYourThoughts #EngageAndConnect
    -Image description

    View inDEV
    Continue readingMarkdown Viewer: one workspace across browser and desktop

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file +Image description

    View inDEV
    Continue readingMarkdown Viewer: one workspace across browser and desktop

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/blog/record-and-process-audio-with-python-2a3b.html b/blog/record-and-process-audio-with-python-2a3b.html index 541d133..c2a4d20 100644 --- a/blog/record-and-process-audio-with-python-2a3b.html +++ b/blog/record-and-process-audio-with-python-2a3b.html @@ -31,7 +31,7 @@ - + @@ -91,4 +91,4 @@ plot_spectrogram(audio_data, sample_rate, output_path) print("Plots saved as 'waveform.png' and 'spectrogram.png' in the current directory.") -
    View inDEV
    Continue readingConnecting a portfolio contact form to Google Sheets

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file +
    View inDEV
    Continue readingConnecting a portfolio contact form to Google Sheets

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/blog/secure-your-github-commits-with-verification-3hja.html b/blog/secure-your-github-commits-with-verification-3hja.html index 6897854..3c855d4 100644 --- a/blog/secure-your-github-commits-with-verification-3hja.html +++ b/blog/secure-your-github-commits-with-verification-3hja.html @@ -31,7 +31,7 @@ - + @@ -310,4 +310,4 @@

    Congratulations!

    -

    Image description

    View inDEV
    Continue readingBuilding a Markdown viewer for the web

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file +

    Image description

    View inDEV
    Continue readingBuilding a Markdown viewer for the web

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/editorial.css b/editorial.css index a8f591b..ca68476 100644 --- a/editorial.css +++ b/editorial.css @@ -399,9 +399,8 @@ } .dev-publication img { width: 28px; - height: 32px; - background: #fff; - border-radius: 4px; + height: 28px; + flex-shrink: 0; } .editorial-reader .next-reading { border: 0; diff --git a/index.html b/index.html index b1126ca..f01126c 100644 --- a/index.html +++ b/index.html @@ -31,7 +31,7 @@ - + diff --git a/play-lab.html b/play-lab.html index 41bf0be..249def4 100644 --- a/play-lab.html +++ b/play-lab.html @@ -31,7 +31,7 @@ - + diff --git a/project.html b/project.html index 505f637..6a38e7c 100644 --- a/project.html +++ b/project.html @@ -31,7 +31,7 @@ - + diff --git a/scripts/build.mjs b/scripts/build.mjs index bacb758..be7604d 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -123,7 +123,7 @@ function metadata(options = {}) { ${options.page ? '' : ""} - + ${!options.page ? '' : ""} diff --git a/scripts/site-pages.mjs b/scripts/site-pages.mjs index 4d50ece..b824e98 100644 --- a/scripts/site-pages.mjs +++ b/scripts/site-pages.mjs @@ -87,7 +87,7 @@ export function renderSitePages(data, ui) { const editorNote = a.editorNote ? `` : ""; - const content = `
    ${toc(entries, "/blog", "Back to blog")}

    ${esc(articleCategory(a))}

    ${esc(a.title)}

    ${esc(a.summary)}

    ${articleArt(a, "article-hero-art")}${editorNote}
    ${a.bodyHtml || ""}
    ${a.url ? `View inDEV${arrow}` : `Published on this website.`}
    ${next && next !== a ? `Continue reading${esc(next.title)}${arrow}` : ""}
    `; + const content = `
    ${toc(entries, "/blog", "Back to blog")}

    ${esc(articleCategory(a))}

    ${esc(a.title)}

    ${esc(a.summary)}

    ${articleArt(a, "article-hero-art")}${editorNote}
    ${a.bodyHtml || ""}
    ${a.url ? `View inDEV${arrow}` : `Published on this website.`}
    ${next && next !== a ? `Continue reading${esc(next.title)}${arrow}` : ""}
    `; pages[`blog/${a.slug}.html`] = shell(content, { path: a.localPath, title: `${a.title} — Baivab Sarkar`, diff --git a/work.html b/work.html index 505f637..6a38e7c 100644 --- a/work.html +++ b/work.html @@ -31,7 +31,7 @@ - + diff --git a/work/ams.html b/work/ams.html index eca5873..c292f05 100644 --- a/work/ams.html +++ b/work/ams.html @@ -31,7 +31,7 @@ - + diff --git a/work/body-language.html b/work/body-language.html index 8509b48..fde9d9d 100644 --- a/work/body-language.html +++ b/work/body-language.html @@ -31,7 +31,7 @@ - + diff --git a/work/csv-chatbot.html b/work/csv-chatbot.html index 9cdc1f4..e9cdf44 100644 --- a/work/csv-chatbot.html +++ b/work/csv-chatbot.html @@ -31,7 +31,7 @@ - + diff --git a/work/enterprise.html b/work/enterprise.html index 58e7b47..e671977 100644 --- a/work/enterprise.html +++ b/work/enterprise.html @@ -31,7 +31,7 @@ - + diff --git a/work/markdown-viewer.html b/work/markdown-viewer.html index d6eefa4..c9aa46d 100644 --- a/work/markdown-viewer.html +++ b/work/markdown-viewer.html @@ -31,7 +31,7 @@ - + diff --git a/work/medical-chatbot.html b/work/medical-chatbot.html index 0362963..8e5a07f 100644 --- a/work/medical-chatbot.html +++ b/work/medical-chatbot.html @@ -31,7 +31,7 @@ - + diff --git a/work/medichain.html b/work/medichain.html index 1c8f587..5ca5808 100644 --- a/work/medichain.html +++ b/work/medichain.html @@ -31,7 +31,7 @@ - + diff --git a/work/news-scraper.html b/work/news-scraper.html index a9d804a..0de3e5d 100644 --- a/work/news-scraper.html +++ b/work/news-scraper.html @@ -31,7 +31,7 @@ - + diff --git a/work/notemarker.html b/work/notemarker.html index 92944d6..0de0927 100644 --- a/work/notemarker.html +++ b/work/notemarker.html @@ -31,7 +31,7 @@ - + diff --git a/work/sei-sangeet-bangla.html b/work/sei-sangeet-bangla.html index c57bc66..4582882 100644 --- a/work/sei-sangeet-bangla.html +++ b/work/sei-sangeet-bangla.html @@ -31,7 +31,7 @@ - + diff --git a/work/simon.html b/work/simon.html index 0851cdb..21fc5fb 100644 --- a/work/simon.html +++ b/work/simon.html @@ -31,7 +31,7 @@ - + diff --git a/work/sketchflow.html b/work/sketchflow.html index d4386e7..cea0728 100644 --- a/work/sketchflow.html +++ b/work/sketchflow.html @@ -31,7 +31,7 @@ - + diff --git a/work/taskflow.html b/work/taskflow.html index 2984495..7069e80 100644 --- a/work/taskflow.html +++ b/work/taskflow.html @@ -31,7 +31,7 @@ - + From 075ddc185c6c908c0dbebca37b18409899303bb1 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Mon, 14 Sep 2026 18:15:40 +0530 Subject: [PATCH 21/22] fix(blog): render DEV logo as inline SVG --- 404.html | 2 +- about.html | 2 +- assets/icons/dev-badge.svg | 1 - blog.html | 2 +- ...es-in-your-browser-your-desktop-and-a-single-url-488m.html | 4 ++-- ...-comprehensive-guide-to-development-and-features-4gpf.html | 4 ++-- blog/do-you-know-llama--3c2d.html | 4 ++-- blog/epic-games-membership-on-github-3ml.html | 4 ++-- blog/introducing-markdown-viewer-v20-com.html | 4 ++-- blog/llama-2-ggml-medical-chatbot-5043.html | 4 ++-- blog/opencvthe-enchanted-palette-part-2-424k.html | 4 ++-- blog/opencvunleashing-the-magic-of-images-1kdb.html | 4 ++-- blog/portfolio-thisis-developer-56bf.html | 4 ++-- blog/record-and-process-audio-with-python-2a3b.html | 4 ++-- blog/secure-your-github-commits-with-verification-3hja.html | 4 ++-- docs/design-system.md | 2 +- editorial.css | 2 +- index.html | 2 +- play-lab.html | 2 +- project.html | 2 +- scripts/build.mjs | 3 +-- scripts/site-pages.mjs | 4 +++- work.html | 2 +- work/ams.html | 2 +- work/body-language.html | 2 +- work/csv-chatbot.html | 2 +- work/enterprise.html | 2 +- work/markdown-viewer.html | 2 +- work/medical-chatbot.html | 2 +- work/medichain.html | 2 +- work/news-scraper.html | 2 +- work/notemarker.html | 2 +- work/sei-sangeet-bangla.html | 2 +- work/simon.html | 2 +- work/sketchflow.html | 2 +- work/taskflow.html | 2 +- 36 files changed, 48 insertions(+), 48 deletions(-) delete mode 100644 assets/icons/dev-badge.svg diff --git a/404.html b/404.html index 23225aa..714882d 100644 --- a/404.html +++ b/404.html @@ -31,7 +31,7 @@ - + diff --git a/about.html b/about.html index 631e721..185aeb0 100644 --- a/about.html +++ b/about.html @@ -31,7 +31,7 @@ - + diff --git a/assets/icons/dev-badge.svg b/assets/icons/dev-badge.svg deleted file mode 100644 index 63fc1ee..0000000 --- a/assets/icons/dev-badge.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/blog.html b/blog.html index 38d6afa..aaeb7d7 100644 --- a/blog.html +++ b/blog.html @@ -31,7 +31,7 @@ - + diff --git a/blog/a-markdown-editor-that-lives-in-your-browser-your-desktop-and-a-single-url-488m.html b/blog/a-markdown-editor-that-lives-in-your-browser-your-desktop-and-a-single-url-488m.html index 5c4b095..b7dc752 100644 --- a/blog/a-markdown-editor-that-lives-in-your-browser-your-desktop-and-a-single-url-488m.html +++ b/blog/a-markdown-editor-that-lives-in-your-browser-your-desktop-and-a-single-url-488m.html @@ -31,7 +31,7 @@ - + @@ -519,4 +519,4 @@

    What's Next?

    -
    View inDEV
    Continue readingInside Markdown Viewer 2.0

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file +
    View in
    Continue readingInside Markdown Viewer 2.0

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/blog/building-a-markdown-viewer-web-application-a-comprehensive-guide-to-development-and-features-4gpf.html b/blog/building-a-markdown-viewer-web-application-a-comprehensive-guide-to-development-and-features-4gpf.html index fefbba8..b2d7c0d 100644 --- a/blog/building-a-markdown-viewer-web-application-a-comprehensive-guide-to-development-and-features-4gpf.html +++ b/blog/building-a-markdown-viewer-web-application-a-comprehensive-guide-to-development-and-features-4gpf.html @@ -31,7 +31,7 @@ - + @@ -197,4 +197,4 @@

    Quick Setup Guide:

    -

    Image description

    View inDEV
    Continue readingQuestion answering over CSV data with Llama 2

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file +

    Image description

    View in
    Continue readingQuestion answering over CSV data with Llama 2

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/blog/do-you-know-llama--3c2d.html b/blog/do-you-know-llama--3c2d.html index 6147d90..aef89ff 100644 --- a/blog/do-you-know-llama--3c2d.html +++ b/blog/do-you-know-llama--3c2d.html @@ -31,7 +31,7 @@ - + @@ -192,4 +192,4 @@

    Important Notes

  • While robust, this chatbot is not a substitute for professional advice.
  • Ensure the CSV file adheres to the expected format for optimal performance.
  • -
    View inDEV
    Continue readingBuilding a document-retrieval prototype with Llama 2

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file +
    View in
    Continue readingBuilding a document-retrieval prototype with Llama 2

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/blog/epic-games-membership-on-github-3ml.html b/blog/epic-games-membership-on-github-3ml.html index c7975a0..5512235 100644 --- a/blog/epic-games-membership-on-github-3ml.html +++ b/blog/epic-games-membership-on-github-3ml.html @@ -31,7 +31,7 @@ - + @@ -175,4 +175,4 @@

    A Word of Caution: Protect Y -

    Success Gif

    View inDEV
    Continue readingSigning Git commits with GPG

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file +

    Success Gif

    View in
    Continue readingSigning Git commits with GPG

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/blog/introducing-markdown-viewer-v20-com.html b/blog/introducing-markdown-viewer-v20-com.html index d0e2422..9d03427 100644 --- a/blog/introducing-markdown-viewer-v20-com.html +++ b/blog/introducing-markdown-viewer-v20-com.html @@ -31,7 +31,7 @@ - + @@ -289,4 +289,4 @@

    Final Thoughts

    -
    View inDEV
    Continue readingManaging public membership in large GitHub organizations

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file +
    View in
    Continue readingManaging public membership in large GitHub organizations

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/blog/llama-2-ggml-medical-chatbot-5043.html b/blog/llama-2-ggml-medical-chatbot-5043.html index 7636591..54962c3 100644 --- a/blog/llama-2-ggml-medical-chatbot-5043.html +++ b/blog/llama-2-ggml-medical-chatbot-5043.html @@ -31,7 +31,7 @@ - + @@ -119,4 +119,4 @@

    PDF

    Conversation examples img-3.pdf

    Image description

    Important Notes

    -
    • While powerful, this chatbot isn't a substitute for professional medical advice.
    View inDEV
    Continue readingImage processing with OpenCV: filters, contours and histograms

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file +
    • While powerful, this chatbot isn't a substitute for professional medical advice.
    View in
    Continue readingImage processing with OpenCV: filters, contours and histograms

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/blog/opencvthe-enchanted-palette-part-2-424k.html b/blog/opencvthe-enchanted-palette-part-2-424k.html index 234468e..3547374 100644 --- a/blog/opencvthe-enchanted-palette-part-2-424k.html +++ b/blog/opencvthe-enchanted-palette-part-2-424k.html @@ -31,7 +31,7 @@ - + @@ -104,4 +104,4 @@

    Analyze an image using Histogr -

    Image description

    View inDEV
    Continue readingGetting started with images in OpenCV

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file +

    Image description

    View in
    Continue readingGetting started with images in OpenCV

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/blog/opencvunleashing-the-magic-of-images-1kdb.html b/blog/opencvunleashing-the-magic-of-images-1kdb.html index 456836c..e87b83f 100644 --- a/blog/opencvunleashing-the-magic-of-images-1kdb.html +++ b/blog/opencvunleashing-the-magic-of-images-1kdb.html @@ -31,7 +31,7 @@ - + @@ -52,4 +52,4 @@

    Image description
    Image description
    -Image description

    View inDEV
    Continue readingRecording and analysing audio with Python

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file +Image description

    View in
    Continue readingRecording and analysing audio with Python

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/blog/portfolio-thisis-developer-56bf.html b/blog/portfolio-thisis-developer-56bf.html index c567265..1ed6049 100644 --- a/blog/portfolio-thisis-developer-56bf.html +++ b/blog/portfolio-thisis-developer-56bf.html @@ -31,7 +31,7 @@ - + @@ -113,4 +113,4 @@

    After sending Message.

    Drop a Comment to Show Your Support and Connect. Let's Make Learning and Creating Together! #ShareYourThoughts #EngageAndConnect
    -Image description

    View inDEV
    Continue readingMarkdown Viewer: one workspace across browser and desktop

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file +Image description

    View in
    Continue readingMarkdown Viewer: one workspace across browser and desktop

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/blog/record-and-process-audio-with-python-2a3b.html b/blog/record-and-process-audio-with-python-2a3b.html index c2a4d20..16eb65f 100644 --- a/blog/record-and-process-audio-with-python-2a3b.html +++ b/blog/record-and-process-audio-with-python-2a3b.html @@ -31,7 +31,7 @@ - + @@ -91,4 +91,4 @@ plot_spectrogram(audio_data, sample_rate, output_path) print("Plots saved as 'waveform.png' and 'spectrogram.png' in the current directory.") -
    View inDEV
    Continue readingConnecting a portfolio contact form to Google Sheets

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file +
    View in
    Continue readingConnecting a portfolio contact form to Google Sheets

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/blog/secure-your-github-commits-with-verification-3hja.html b/blog/secure-your-github-commits-with-verification-3hja.html index 3c855d4..bb9796d 100644 --- a/blog/secure-your-github-commits-with-verification-3hja.html +++ b/blog/secure-your-github-commits-with-verification-3hja.html @@ -31,7 +31,7 @@ - + @@ -310,4 +310,4 @@

    Congratulations!

    -

    Image description

    View inDEV
    Continue readingBuilding a Markdown viewer for the web

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file +

    Image description

    View in
    Continue readingBuilding a Markdown viewer for the web

    An idea, an opportunity, or just a hello?

    Build with me.

    Bug Run

    A tiny break between builds.

    Score0
    Best0
    An optional jumping game. The rest of this portfolio works without JavaScript.

    Space or tap to start. Dodge a few bugs.

    \ No newline at end of file diff --git a/docs/design-system.md b/docs/design-system.md index a56097c..0ccbacc 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -68,4 +68,4 @@ Desktop keeps the five-card wallet fan with 175px cards and a 44px auto -20px ma The dark page canvas is #111113, measured from [Jeet's site](https://jeetcreates.cc/) on September 14, 2026. The saved-theme bootstrap and toolbar colour use the same value. The dark capabilities introduction uses the reference's 135-degree gradient from #454DAE to #5B4FC7 with #EAEAFF text and a soft highlighted icon tile. -Article footers link to the original DEV publication using “View in”, the DEV badge, and an external arrow. The duplicate written-by sentence is removed; the author byline stays in the article header. The badge is from the [official DEV brand assets](https://dev.to/brand), stored at assets/icons/dev-badge.svg. The private-work detail ends at the confidentiality notice without the extra public-work button. +Article footers link to the original DEV publication using “View in”, the DEV badge, and an external arrow. The duplicate written-by sentence is removed; the author byline stays in the article header. The badge uses the [official DEV brand artwork](https://dev.to/brand) as an inline SVG in scripts/site-pages.mjs, with a square 28px footprint and an accessible DEV label. Its white lettering and black background are vector shapes with no external image request. The private-work detail ends at the confidentiality notice without the extra public-work button. diff --git a/editorial.css b/editorial.css index ca68476..31a93eb 100644 --- a/editorial.css +++ b/editorial.css @@ -397,7 +397,7 @@ min-height: 44px; gap: 8px; } -.dev-publication img { +.dev-publication .dev-logo { width: 28px; height: 28px; flex-shrink: 0; diff --git a/index.html b/index.html index f01126c..97c1513 100644 --- a/index.html +++ b/index.html @@ -31,7 +31,7 @@ - + diff --git a/play-lab.html b/play-lab.html index 249def4..538f104 100644 --- a/play-lab.html +++ b/play-lab.html @@ -31,7 +31,7 @@ - + diff --git a/project.html b/project.html index 6a38e7c..7ebdb09 100644 --- a/project.html +++ b/project.html @@ -31,7 +31,7 @@ - + diff --git a/scripts/build.mjs b/scripts/build.mjs index be7604d..6b74c71 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -123,7 +123,7 @@ function metadata(options = {}) { ${options.page ? '' : ""} - + ${!options.page ? '' : ""} @@ -248,7 +248,6 @@ if (!check) { "profile/baivab-800.webp", "social-preview.png", "apple-touch-icon.png", - "icons/dev-badge.svg", "resume/Baivab_Sarkar_Resume.pdf", "resume/CV-BAIVAB SARKAR.pdf", ]) { diff --git a/scripts/site-pages.mjs b/scripts/site-pages.mjs index b824e98..7148a77 100644 --- a/scripts/site-pages.mjs +++ b/scripts/site-pages.mjs @@ -12,6 +12,8 @@ const esc = (value = "") => ); const arrow = ''; +const devLogo = + ''; const date = (value) => new Intl.DateTimeFormat("en", { month: "long", @@ -87,7 +89,7 @@ export function renderSitePages(data, ui) { const editorNote = a.editorNote ? `` : ""; - const content = `
    ${toc(entries, "/blog", "Back to blog")}

    ${esc(articleCategory(a))}

    ${esc(a.title)}

    ${esc(a.summary)}

    ${articleArt(a, "article-hero-art")}${editorNote}
    ${a.bodyHtml || ""}
    ${a.url ? `View inDEV${arrow}` : `Published on this website.`}
    ${next && next !== a ? `Continue reading${esc(next.title)}${arrow}` : ""}
    `; + const content = `
    ${toc(entries, "/blog", "Back to blog")}

    ${esc(articleCategory(a))}

    ${esc(a.title)}

    ${esc(a.summary)}

    ${articleArt(a, "article-hero-art")}${editorNote}
    ${a.bodyHtml || ""}
    ${a.url ? `View in${devLogo}${arrow}` : `Published on this website.`}
    ${next && next !== a ? `Continue reading${esc(next.title)}${arrow}` : ""}
    `; pages[`blog/${a.slug}.html`] = shell(content, { path: a.localPath, title: `${a.title} — Baivab Sarkar`, diff --git a/work.html b/work.html index 6a38e7c..7ebdb09 100644 --- a/work.html +++ b/work.html @@ -31,7 +31,7 @@ - + diff --git a/work/ams.html b/work/ams.html index c292f05..81e4541 100644 --- a/work/ams.html +++ b/work/ams.html @@ -31,7 +31,7 @@ - + diff --git a/work/body-language.html b/work/body-language.html index fde9d9d..4b0a739 100644 --- a/work/body-language.html +++ b/work/body-language.html @@ -31,7 +31,7 @@ - + diff --git a/work/csv-chatbot.html b/work/csv-chatbot.html index e9cdf44..69581ba 100644 --- a/work/csv-chatbot.html +++ b/work/csv-chatbot.html @@ -31,7 +31,7 @@ - + diff --git a/work/enterprise.html b/work/enterprise.html index e671977..3780d26 100644 --- a/work/enterprise.html +++ b/work/enterprise.html @@ -31,7 +31,7 @@ - + diff --git a/work/markdown-viewer.html b/work/markdown-viewer.html index c9aa46d..9e358b6 100644 --- a/work/markdown-viewer.html +++ b/work/markdown-viewer.html @@ -31,7 +31,7 @@ - + diff --git a/work/medical-chatbot.html b/work/medical-chatbot.html index 8e5a07f..a0281fd 100644 --- a/work/medical-chatbot.html +++ b/work/medical-chatbot.html @@ -31,7 +31,7 @@ - + diff --git a/work/medichain.html b/work/medichain.html index 5ca5808..53e7edd 100644 --- a/work/medichain.html +++ b/work/medichain.html @@ -31,7 +31,7 @@ - + diff --git a/work/news-scraper.html b/work/news-scraper.html index 0de3e5d..525a7e5 100644 --- a/work/news-scraper.html +++ b/work/news-scraper.html @@ -31,7 +31,7 @@ - + diff --git a/work/notemarker.html b/work/notemarker.html index 0de0927..3d32ece 100644 --- a/work/notemarker.html +++ b/work/notemarker.html @@ -31,7 +31,7 @@ - + diff --git a/work/sei-sangeet-bangla.html b/work/sei-sangeet-bangla.html index 4582882..c17e552 100644 --- a/work/sei-sangeet-bangla.html +++ b/work/sei-sangeet-bangla.html @@ -31,7 +31,7 @@ - + diff --git a/work/simon.html b/work/simon.html index 21fc5fb..4d1fcca 100644 --- a/work/simon.html +++ b/work/simon.html @@ -31,7 +31,7 @@ - + diff --git a/work/sketchflow.html b/work/sketchflow.html index cea0728..0eef4ec 100644 --- a/work/sketchflow.html +++ b/work/sketchflow.html @@ -31,7 +31,7 @@ - + diff --git a/work/taskflow.html b/work/taskflow.html index 7069e80..9679f3c 100644 --- a/work/taskflow.html +++ b/work/taskflow.html @@ -31,7 +31,7 @@ - + From 510e33d337a5ad90308e6551282d856bea4e3f1d Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Mon, 14 Sep 2026 18:21:41 +0530 Subject: [PATCH 22/22] fix(navigation): keep theme icon centered when collapsed --- 404.html | 2 +- about.html | 2 +- blog.html | 2 +- ...es-in-your-browser-your-desktop-and-a-single-url-488m.html | 2 +- ...-comprehensive-guide-to-development-and-features-4gpf.html | 2 +- blog/do-you-know-llama--3c2d.html | 2 +- blog/epic-games-membership-on-github-3ml.html | 2 +- blog/introducing-markdown-viewer-v20-com.html | 2 +- blog/llama-2-ggml-medical-chatbot-5043.html | 2 +- blog/opencvthe-enchanted-palette-part-2-424k.html | 2 +- blog/opencvunleashing-the-magic-of-images-1kdb.html | 2 +- blog/portfolio-thisis-developer-56bf.html | 2 +- blog/record-and-process-audio-with-python-2a3b.html | 2 +- blog/secure-your-github-commits-with-verification-3hja.html | 2 +- index.html | 2 +- play-lab.html | 2 +- project.html | 2 +- scripts/build.mjs | 2 +- style.css | 4 +--- work.html | 2 +- work/ams.html | 2 +- work/body-language.html | 2 +- work/csv-chatbot.html | 2 +- work/enterprise.html | 2 +- work/markdown-viewer.html | 2 +- work/medical-chatbot.html | 2 +- work/medichain.html | 2 +- work/news-scraper.html | 2 +- work/notemarker.html | 2 +- work/sei-sangeet-bangla.html | 2 +- work/simon.html | 2 +- work/sketchflow.html | 2 +- work/taskflow.html | 2 +- 33 files changed, 33 insertions(+), 35 deletions(-) diff --git a/404.html b/404.html index 714882d..c734cf9 100644 --- a/404.html +++ b/404.html @@ -28,7 +28,7 @@ - + diff --git a/about.html b/about.html index 185aeb0..a315d46 100644 --- a/about.html +++ b/about.html @@ -28,7 +28,7 @@ - + diff --git a/blog.html b/blog.html index aaeb7d7..9edf286 100644 --- a/blog.html +++ b/blog.html @@ -28,7 +28,7 @@ - + diff --git a/blog/a-markdown-editor-that-lives-in-your-browser-your-desktop-and-a-single-url-488m.html b/blog/a-markdown-editor-that-lives-in-your-browser-your-desktop-and-a-single-url-488m.html index b7dc752..e827a86 100644 --- a/blog/a-markdown-editor-that-lives-in-your-browser-your-desktop-and-a-single-url-488m.html +++ b/blog/a-markdown-editor-that-lives-in-your-browser-your-desktop-and-a-single-url-488m.html @@ -28,7 +28,7 @@ - + diff --git a/blog/building-a-markdown-viewer-web-application-a-comprehensive-guide-to-development-and-features-4gpf.html b/blog/building-a-markdown-viewer-web-application-a-comprehensive-guide-to-development-and-features-4gpf.html index b2d7c0d..2a6c8c5 100644 --- a/blog/building-a-markdown-viewer-web-application-a-comprehensive-guide-to-development-and-features-4gpf.html +++ b/blog/building-a-markdown-viewer-web-application-a-comprehensive-guide-to-development-and-features-4gpf.html @@ -28,7 +28,7 @@ - + diff --git a/blog/do-you-know-llama--3c2d.html b/blog/do-you-know-llama--3c2d.html index aef89ff..ded144b 100644 --- a/blog/do-you-know-llama--3c2d.html +++ b/blog/do-you-know-llama--3c2d.html @@ -28,7 +28,7 @@ - + diff --git a/blog/epic-games-membership-on-github-3ml.html b/blog/epic-games-membership-on-github-3ml.html index 5512235..6512e5a 100644 --- a/blog/epic-games-membership-on-github-3ml.html +++ b/blog/epic-games-membership-on-github-3ml.html @@ -28,7 +28,7 @@ - + diff --git a/blog/introducing-markdown-viewer-v20-com.html b/blog/introducing-markdown-viewer-v20-com.html index 9d03427..96d1964 100644 --- a/blog/introducing-markdown-viewer-v20-com.html +++ b/blog/introducing-markdown-viewer-v20-com.html @@ -28,7 +28,7 @@ - + diff --git a/blog/llama-2-ggml-medical-chatbot-5043.html b/blog/llama-2-ggml-medical-chatbot-5043.html index 54962c3..da7c003 100644 --- a/blog/llama-2-ggml-medical-chatbot-5043.html +++ b/blog/llama-2-ggml-medical-chatbot-5043.html @@ -28,7 +28,7 @@ - + diff --git a/blog/opencvthe-enchanted-palette-part-2-424k.html b/blog/opencvthe-enchanted-palette-part-2-424k.html index 3547374..c1ed83f 100644 --- a/blog/opencvthe-enchanted-palette-part-2-424k.html +++ b/blog/opencvthe-enchanted-palette-part-2-424k.html @@ -28,7 +28,7 @@ - + diff --git a/blog/opencvunleashing-the-magic-of-images-1kdb.html b/blog/opencvunleashing-the-magic-of-images-1kdb.html index e87b83f..5dab79d 100644 --- a/blog/opencvunleashing-the-magic-of-images-1kdb.html +++ b/blog/opencvunleashing-the-magic-of-images-1kdb.html @@ -28,7 +28,7 @@ - + diff --git a/blog/portfolio-thisis-developer-56bf.html b/blog/portfolio-thisis-developer-56bf.html index 1ed6049..9cb78d3 100644 --- a/blog/portfolio-thisis-developer-56bf.html +++ b/blog/portfolio-thisis-developer-56bf.html @@ -28,7 +28,7 @@ - + diff --git a/blog/record-and-process-audio-with-python-2a3b.html b/blog/record-and-process-audio-with-python-2a3b.html index 16eb65f..1d8e885 100644 --- a/blog/record-and-process-audio-with-python-2a3b.html +++ b/blog/record-and-process-audio-with-python-2a3b.html @@ -28,7 +28,7 @@ - + diff --git a/blog/secure-your-github-commits-with-verification-3hja.html b/blog/secure-your-github-commits-with-verification-3hja.html index bb9796d..0edb1dd 100644 --- a/blog/secure-your-github-commits-with-verification-3hja.html +++ b/blog/secure-your-github-commits-with-verification-3hja.html @@ -28,7 +28,7 @@ - + diff --git a/index.html b/index.html index 97c1513..017bfc5 100644 --- a/index.html +++ b/index.html @@ -28,7 +28,7 @@ - + diff --git a/play-lab.html b/play-lab.html index 538f104..8d920b7 100644 --- a/play-lab.html +++ b/play-lab.html @@ -28,7 +28,7 @@ - + diff --git a/project.html b/project.html index 7ebdb09..d01dbde 100644 --- a/project.html +++ b/project.html @@ -28,7 +28,7 @@ - + diff --git a/scripts/build.mjs b/scripts/build.mjs index 6b74c71..d05ce40 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -120,7 +120,7 @@ function metadata(options = {}) { - + ${options.page ? '' : ""} diff --git a/style.css b/style.css index ed4a932..2e4d3b2 100644 --- a/style.css +++ b/style.css @@ -345,6 +345,7 @@ input { } .theme-toggle { border: 0; + padding: 0; background: var(--glass); border-radius: 50%; display: grid; @@ -2349,9 +2350,6 @@ input { a:not(:nth-child(1)):not(:nth-child(3)) { display: none; } - .nav-ready #site-nav[data-open="false"] + .theme-toggle { - display: flex; - } } /* Controls sit on solid surfaces; pointer clicks do not draw keyboard rings. */ diff --git a/work.html b/work.html index 7ebdb09..d01dbde 100644 --- a/work.html +++ b/work.html @@ -28,7 +28,7 @@ - + diff --git a/work/ams.html b/work/ams.html index 81e4541..14f0061 100644 --- a/work/ams.html +++ b/work/ams.html @@ -28,7 +28,7 @@ - + diff --git a/work/body-language.html b/work/body-language.html index 4b0a739..2129a68 100644 --- a/work/body-language.html +++ b/work/body-language.html @@ -28,7 +28,7 @@ - + diff --git a/work/csv-chatbot.html b/work/csv-chatbot.html index 69581ba..4cd414c 100644 --- a/work/csv-chatbot.html +++ b/work/csv-chatbot.html @@ -28,7 +28,7 @@ - + diff --git a/work/enterprise.html b/work/enterprise.html index 3780d26..9a1b026 100644 --- a/work/enterprise.html +++ b/work/enterprise.html @@ -28,7 +28,7 @@ - + diff --git a/work/markdown-viewer.html b/work/markdown-viewer.html index 9e358b6..d4d4263 100644 --- a/work/markdown-viewer.html +++ b/work/markdown-viewer.html @@ -28,7 +28,7 @@ - + diff --git a/work/medical-chatbot.html b/work/medical-chatbot.html index a0281fd..0daaaeb 100644 --- a/work/medical-chatbot.html +++ b/work/medical-chatbot.html @@ -28,7 +28,7 @@ - + diff --git a/work/medichain.html b/work/medichain.html index 53e7edd..d979da6 100644 --- a/work/medichain.html +++ b/work/medichain.html @@ -28,7 +28,7 @@ - + diff --git a/work/news-scraper.html b/work/news-scraper.html index 525a7e5..6979673 100644 --- a/work/news-scraper.html +++ b/work/news-scraper.html @@ -28,7 +28,7 @@ - + diff --git a/work/notemarker.html b/work/notemarker.html index 3d32ece..7dabe41 100644 --- a/work/notemarker.html +++ b/work/notemarker.html @@ -28,7 +28,7 @@ - + diff --git a/work/sei-sangeet-bangla.html b/work/sei-sangeet-bangla.html index c17e552..5e076b3 100644 --- a/work/sei-sangeet-bangla.html +++ b/work/sei-sangeet-bangla.html @@ -28,7 +28,7 @@ - + diff --git a/work/simon.html b/work/simon.html index 4d1fcca..be6fa00 100644 --- a/work/simon.html +++ b/work/simon.html @@ -28,7 +28,7 @@ - + diff --git a/work/sketchflow.html b/work/sketchflow.html index 0eef4ec..aa7b5d5 100644 --- a/work/sketchflow.html +++ b/work/sketchflow.html @@ -28,7 +28,7 @@ - + diff --git a/work/taskflow.html b/work/taskflow.html index 9679f3c..a383714 100644 --- a/work/taskflow.html +++ b/work/taskflow.html @@ -28,7 +28,7 @@ - +