diff --git a/404.html b/404.html index f3d7844..c734cf9 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..a315d46 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..9edf286 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..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 @@ -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.

@@ -518,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 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 e9b971e..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 @@ -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.

@@ -196,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 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 9e6f6ad..ded144b 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

@@ -191,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 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 bee809c..6512e5a 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

    @@ -174,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 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 0ced274..96d1964 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.

    @@ -288,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 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 69480e5..da7c003 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.

    @@ -118,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 +
    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 e61dd80..c1ed83f 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
    @@ -103,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 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 6d468e3..5dab79d 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.

    @@ -51,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 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 5316e98..9cb78d3 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

    @@ -112,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 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 9f059fa..1d8e885 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.

    @@ -90,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 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 f675db3..0edb1dd 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

    @@ -309,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 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/cursor.js b/cursor.js index 4a9a9f7..124e589 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); @@ -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..0ccbacc 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -9,16 +9,20 @@ 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.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 | | 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. +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. + ## Page composition - Home: centered 88svh introduction, six featured project folders, timed capabilities, selected writing, personal cards and dark contact/game ending. @@ -26,16 +30,16 @@ 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 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. ## 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. 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 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 @@ -56,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 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 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 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. -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. +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. -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. +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 a1627ce..31a93eb 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; } @@ -348,6 +393,15 @@ border: 0; padding-top: 40px; } +.article-credit .dev-publication { + min-height: 44px; + gap: 8px; +} +.dev-publication .dev-logo { + width: 28px; + height: 28px; + flex-shrink: 0; +} .editorial-reader .next-reading { border: 0; background: var(--strong-glass); diff --git a/index.html b/index.html index 3b99fad..017bfc5 100644 --- a/index.html +++ b/index.html @@ -7,6 +7,7 @@ + @@ -27,22 +28,22 @@ - + - + - +

    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 + On my workbenchMarkdown ViewerAlways another idea. + Hello, I’mBaivab SarkarCurious by default. + My foundationComputer scienceJIS · Class of 2025 + Learning in publicBuild & writeNotes from the process. + Find me in the openThisIs-DeveloperWest Bengal, India +

    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/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 67885b5..8d920b7 100644 --- a/play-lab.html +++ b/play-lab.html @@ -7,6 +7,7 @@ + @@ -27,18 +28,19 @@ - + - + - +

    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

    @@ -47,14 +49,18 @@

    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
    +
    -

    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..2591d7b 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; @@ -851,28 +849,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 +870,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"] { @@ -1435,3 +1411,235 @@ .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; } + [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-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 { 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); } + .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: 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; } +} +@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; } + +@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; + } +} +.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 { 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 { 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; } +.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; } } + +/* 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 c1d3aa0..66077e9 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; @@ -78,10 +78,11 @@ 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 }; + const canvasArea = { width: 0, height: 0, compact: null }; let layer = 2; let saved; try { @@ -96,26 +97,39 @@ } function arrange() { if (list || !board.clientWidth) return; - fit = Math.max( - 0.8, - Math.min( - (board.clientWidth - 12) / world.offsetWidth, - (board.clientHeight - 88) / 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; if (!camera.ready) { - camera.x = compactScreen.matches - ? -10 - : (board.clientWidth - world.offsetWidth * scale) / 2; - camera.y = Math.max( - 18, - (board.clientHeight - 88 - world.offsetHeight * scale) / 2, - ); + camera.x = (board.clientWidth - canvasArea.width * scale) / 2; + camera.y = (board.clientHeight - canvasArea.height * scale) / 2; 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; @@ -124,6 +138,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, 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; + 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`; @@ -133,23 +157,77 @@ 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; - const pad = 18; + // Card limits belong to the finite world, independently of camera and zoom. + 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: (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: 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() { + let layoutWidth = canvasArea.width * 0.65, layoutHeight = canvasArea.height * 0.65; + const widest = Math.max(...cards.map(card => card.offsetWidth)) + 48; + 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--) { + 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, + 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); @@ -167,6 +245,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; @@ -412,6 +491,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 +501,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 +516,183 @@ }); }); 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]")]; + 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); + }); + } + window.addEventListener("blur", cancelPinDrag); + document.addEventListener("playlab:panelchange", cancelPinDrag); + function pinCard(card, colour) { + stopMovement(); + card.dataset.pinned = colour; + card.querySelector(".playground-card-pin").hidden = false; + 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("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); + } + pinDrag = null; + pinGhost?.remove(); + pinGhost = null; + if (tool.hasPointerCapture(event.pointerId)) tool.releasePointerCapture(event.pointerId); + }; + ["pointerup", "pointercancel", "lostpointercapture"].forEach(type => tool.addEventListener(type, releasePin)); + } + const activityEvents = ["pointerdown", "pointermove", "click", "wheel", "keydown", "input"]; + const hideInstructions = () => { + board.querySelector("#playground-instructions").hidden = true; + 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(); + }); board.querySelectorAll("[data-playground-color]").forEach((button) => button.addEventListener("click", () => { saved.color = button.dataset.playgroundColor; @@ -438,28 +700,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.`); }), ); @@ -470,8 +739,10 @@ zoom = 1; camera.ready = false; for (const [card, position] of positions) { - position.x = 0; - position.y = 0; + delete card.dataset.pinned; + card.querySelector(".playground-card-pin").hidden = true; + position.x = position.homeX; + position.y = position.homeY; card.style.zIndex = ""; paint(card); } @@ -480,6 +751,7 @@ }); function setList(next) { stopMovement(); + cancelPinDrag(); list = next; board.classList.toggle("is-list", list); view.setAttribute("aria-pressed", String(list)); @@ -496,7 +768,7 @@ view.addEventListener("click", () => setList(!list)); compactScreen.addEventListener("change", () => { camera.ready = false; - setList(compactScreen.matches); + setList(list); }); board.addEventListener("pointerdown", (event) => { if ( @@ -508,6 +780,7 @@ return; event.preventDefault(); stopMovement(); + window.getSelection()?.removeAllRanges(); board.focus({ preventScroll: true }); panDrag = { id: event.pointerId, @@ -596,6 +869,7 @@ card.addEventListener("pointerdown", (event) => { if ( list || + card.dataset.pinned || event.button !== 0 || drag || panDrag || @@ -605,6 +879,7 @@ ) return; event.preventDefault(); + window.getSelection()?.removeAllRanges(); camera.vx = camera.vy = 0; card.focus({ preventScroll: true }); const position = positions.get(card); @@ -616,6 +891,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, @@ -634,16 +911,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( @@ -661,9 +929,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; @@ -698,6 +968,7 @@ card.addEventListener("keydown", (event) => { if ( list || + card.dataset.pinned || event.target !== card || !["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes( event.key, @@ -767,6 +1038,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(); @@ -826,15 +1098,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 +1285,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 +1427,31 @@ heights, previousHeights, waterPixels, - bedPixels; - function waterBed() { - if (!waterWidth) return; + waterTint; + let waterDark = false; + 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], - }[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; - } + 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]; } 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 ? (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); } function sizeWater() { @@ -1235,7 +1464,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 +1482,7 @@ heights[i + waterWidth]) * 0.5 - previousHeights[i]) * - 0.982; + 0.97; previousHeights[i] = next; energy = Math.max(energy, Math.abs(next)); } @@ -1315,20 +1544,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(); @@ -1353,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 = @@ -1361,7 +1594,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/project.html b/project.html index b987cce..d01dbde 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 2586f1d..ba6f4b6 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 ? 580 : 460, 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 ? 600 : 500, 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(false); + 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)); - let previousScroll = 0; + smallScreen.addEventListener("change", syncNavigation); + window.addEventListener("pageshow", syncNavigation); + let previousScroll = window.scrollY; window.addEventListener( "scroll", () => { - if ( - !smallScreen.matches && - Math.abs(window.scrollY - previousScroll) > 150 - ) { - 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 }, ); @@ -126,15 +130,18 @@ if (themeToggle) { themeToggle.setAttribute("aria-pressed", String(dark)); document .querySelector('meta[name="theme-color"]') - ?.setAttribute("content", dark ? "#18191c" : "#f5f4f0"); + ?.setAttribute("content", dark ? "#111113" : "#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"; @@ -271,7 +278,7 @@ const wallet = document.querySelector(".wallet"); if (wallet) { wallet.querySelectorAll(".wallet-card").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 c1496d7..d05ce40 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/dot-checks.mjs b/scripts/dot-checks.mjs index 462ec68..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, "44px", `Wallet top margin at ${width}px`); - assert.equal(wallet.bottom, "-20px", `Wallet overlap 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 === "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`, ); @@ -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 >= 80 && + 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,35 @@ 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" }); + // 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 <= 34 && sample.max < center.max, + (sample) => sample.max === darkCorner.max, ); assert( - darkNormal.max < center.max, - `Dark mode reduces normal dot opacity: ${JSON.stringify({ center, darkNormal })}`, + darkNormal.max >= 50 && 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/home-sections.mjs b/scripts/home-sections.mjs index 8c7cfc7..a9cee47 100644 --- a/scripts/home-sections.mjs +++ b/scripts/home-sections.mjs @@ -2,12 +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")}
    `; + On my workbenchMarkdown ViewerAlways another idea. + Hello, I’mBaivab SarkarCurious by default. + My foundationComputer scienceJIS · Class of 2025 + Learning in publicBuild & writeNotes from the process. + Find me in the openThisIs-DeveloperWest Bengal, India +
    ${quickAsk()}`; } diff --git a/scripts/motion-checks.mjs b/scripts/motion-checks.mjs index db83240..3632d13 100644 --- a/scripts/motion-checks.mjs +++ b/scripts/motion-checks.mjs @@ -61,6 +61,49 @@ 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"); + 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 + pinTarget.height / 2, { steps: 12 }); + await page.mouse.up(); + 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(); + 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( @@ -74,14 +117,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(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, - "A card moves into newly exposed space at 65% zoom", + Math.abs(held.x - before.x) > 100, + "A card moves across the finite world at 65% zoom", ); await page.mouse.up(); await page.waitForTimeout(1400); @@ -93,10 +136,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(); @@ -105,6 +148,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(); @@ -144,3 +189,122 @@ 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`); + } + 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 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 board.scrollIntoViewIfNeeded(); + let rect = await board.boundingBox(); + const cameraBefore = await world.getAttribute("style"); + 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(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"); + 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(box.x + box.width / 2 + dx, box.y + box.height / 2, { steps: 8 }); + await page.waitForTimeout(300); + await page.mouse.up(); + 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"); + } +} 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/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 1127d15..206198b 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

    @@ -24,18 +25,22 @@ 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
    +
    -

    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.

    diff --git a/scripts/refinement-checks.mjs b/scripts/refinement-checks.mjs index 8c3011a..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?\.3\)/, + /0?\.36\)/, ); const project = page.locator('a.folder[href="/work/body-language"]'); await project.scrollIntoViewIfNeeded(); diff --git a/scripts/site-pages.mjs b/scripts/site-pages.mjs index 7bdee62..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 || ""}

    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 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`, @@ -131,7 +133,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/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..e9b54ad --- /dev/null +++ b/scripts/theme-checks.mjs @@ -0,0 +1,160 @@ +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(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"), "#111113"); + } 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" }); + 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"); + 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"); + } + } + } + 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(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`); + } + 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 2b0aef6..5beaffa 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -7,8 +7,12 @@ 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"; +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) => @@ -274,6 +278,34 @@ 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: 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", + ); + 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"); @@ -1071,7 +1103,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"); @@ -1107,7 +1139,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(); @@ -1417,6 +1449,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), @@ -1428,7 +1461,7 @@ try { ], [ "zoomed canvas, tab isolation and water feedback", - () => canvasMotion(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)], @@ -1442,8 +1475,9 @@ try { () => playground(page), ], ["cursor highlights and reduced motion", () => cursorDots(page)], + ["overlapping mobile wallet, links and no-scroll layout", () => walletChecks(browser, preview.url)], [ - "tight dot masks, edge fading, dark hover and compact wallet", + "tight dot masks, uniform edges, dark hover and compact wallet", () => dotChecks(page, load), ], [ @@ -1596,8 +1630,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(), diff --git a/scripts/wallet-checks.mjs b/scripts/wallet-checks.mjs new file mode 100644 index 0000000..5407aa7 --- /dev/null +++ b/scripts/wallet-checks.mjs @@ -0,0 +1,72 @@ +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/markdown-viewer", "/about", "/about#experience", "/blog"]; + for (const width of [320, 390, 440, 767]) { + await page.setViewportSize({ width, height: 956 }); + await page.goto(`${url}/#about`, { waitUntil: "networkidle" }); + for (const dark of [false, true]) { + if (await page.locator(".theme-toggle").getAttribute("aria-pressed") !== String(dark)) + await page.locator(".theme-toggle").dispatchEvent("click"); + 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("#wallet-work").tap(); + await page.waitForURL(`${url}/work/markdown-viewer`); + await page.goto(`${url}/#about`, { waitUntil: "networkidle" }); + 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 five-card fan"); + await page.setViewportSize({ width: 390, height: 956 }); + 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, [], "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(), 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 961bfff..2e4d3b2 100644 --- a/style.css +++ b/style.css @@ -30,20 +30,26 @@ --line: rgb(0 0 0 / 0.075); --glass: #fbfaf7; --strong-glass: #fff; - --dot: rgb(80 80 75 / 0.3); + --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); color-scheme: light; } :root[data-theme="dark"] { - --paper: #18191c; + --paper: #111113; --ink: #f3f2ed; --muted: #aaaab3; --blue: #a0afff; --line: rgb(255 255 255 / 0.1); --glass: #292a31; --strong-glass: #34353d; - --dot: rgb(200 200 210 / 0.13); + --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); @@ -324,6 +345,7 @@ input { } .theme-toggle { border: 0; + padding: 0; background: var(--glass); border-radius: 50%; display: grid; @@ -677,6 +699,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)); @@ -1998,6 +2032,7 @@ input { border-radius: 0; padding: 0; background: transparent; + -webkit-backdrop-filter: none; backdrop-filter: none; box-shadow: none; justify-content: center; @@ -2077,6 +2112,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; @@ -2294,15 +2337,19 @@ 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"] 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. */ @@ -2605,43 +2652,65 @@ input { width: 38px; height: 38px; } + /* Four overlapping personal cards sit directly above Quick Ask. */ .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: start; + gap: 0; + max-width: 420px; + overflow: visible; + padding: 12px 8px 0; + margin: 28px auto -14px; width: 100%; - scroll-snap-type: x proximity; - scrollbar-width: none; - } - .wallet::-webkit-scrollbar { - display: none; } .wallet-card { - width: 156px; - height: 175px; - padding: 12px 15px 14px; - margin: 0 -4px; - scroll-snap-align: center; - flex-shrink: 0; + width: 100%; + min-width: 0; + height: 148px; + padding: 10px 12px; + margin: 0; + 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(-20px) rotate(0deg); + transform: translateY(-4px) rotate(0deg); } } @media (prefers-reduced-motion: reduce) { diff --git a/theme.js b/theme.js new file mode 100644 index 0000000..ab0dd4c --- /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 ? "#111113" : "#f5f4f0"); +})(); diff --git a/work.html b/work.html index b987cce..d01dbde 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..14f0061 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..2129a68 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..4cd414c 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..9a1b026 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 +

    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 158c17c..d4d4263 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..0daaaeb 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..d979da6 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..6979673 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..7dabe41 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..5e076b3 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..be6fa00 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..aa7b5d5 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..a383714 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