Personal portfolio site to share (1) about myself + (2) content I've written. This v2 focuses on streamlining DX by leaning into CSS Variables.
All commands are run from the root of the project, from a terminal.
Command | Action |
---|---|
pnpm install |
Installs dependencies |
pnpm dev |
Starts local dev server at localhost:4321 |
pnpm dev:nocache |
Runs dev without cloudinary:cache |
pnpm build |
Build production site to ./dist/ (with cloudinary:cache + git submodule update from latest remote) |
pnpm build:nocache |
Only build production site |
pnpm preview |
Build (cache + submodule) then preview locally, before deploying |
pnpm preview:nocache |
Same as preview , but don't cloudinary:cache when building |
pnpm preview:nobuild |
Same as preview , but without build step, so directly re-using existing local build |
pnpm format:check |
Print code-format results with Prettier |
pnpm format:fix |
Format all code with Prettier (will write) |
pnpm cloudinary:cache |
Pre-cache Cloudinary images with its API |
pnpm gdrive:download |
Download Google Drive folder to local path |
pnpm setup:external |
Alias for gdrive:download && cloudinary:cache |
pnpm astro ... |
Run CLI commands like astro add , astro preview |
Check package.json for specific implementation.
As of latest:
- Web Framework: Astro
- UI Component(s) Library: Radix Primitives
- UI Library: React
- CSS/Design
- Methodology: CUBE CSS
- CSS Tokens: Open Props
- CSS Extension: Sass/Scss
- Preprocessing: PostCSS
- Fluid Responsive Design: utopia-core-scss @ Utopia
- Deployment + CD: Cloudflare Pages
- Image CDN: Cloudinary
- CMS: Obsidian + GitHub repo
.
├── lib
│ ├── *
│ └── utils // utilities scoped to /lib
├── patches // generated by `pnpm patch`
├── public
│ ├── assets
│ │ ├── favicon
│ │ └── fonts
│ └── _headers
└── src
├── assets // optimised local image asset(s)
├── components
│ ├── block // has logic, or has many elements
│ ├── layout
│ └── styled // just visually styled, no logic
│ ├── monom / mono-morphic, fixed final HTML tag
│ └── polym // poly-morphic, dynamic final HTML tag
├── content
│ ├── project
│ ├── obsidian-note // git submodule → obsidian-caleb-public
│ │ └── **.md
│ └── config.ts
├── data // structured static data
├── pages
│ └── **
│ └── _components // scoped helper components
├── styles
│ ├── config
│ │ ├── dynamic-colors.scss // color calculations
│ │ ├── fluid.scss // utopia-core-scss fluid type generators
│ │ ├── fonts.css // font imports
│ │ ├── misc.scss
│ │ ├── theme.scss // theme config
│ │ └── composer.scss // composes everything in style rules
│ │ └── index.css // default export
│ ├── normalize // reset default agent styles
│ ├── overrides // custom site styles
│ ├── utilities // does one job well
│ └── global.css // main css entry point
└── utils // /src-specific utilities
Astro looks for .astro or .md files in the src/pages/
directory. Each page is exposed as a route based on its file name, except if a route segment starts with an _
(like _components
).
There's nothing special about src/components/
, but that's where any Astro/React/Vue/Svelte/Preact/etc. components live.
Any static assets, like images, can be placed in the public/
directory if they do not require any transformation or in the assets/
directory if they are imported directly.
This repo is the second iteration of my personal site, after chuangcaleb/v1.chuangcaleb.com.
Back then, I was still super novice at this whole web thing. I picked up the new trending tech (Astro + Tailwind). I'm proud of it, but I think my skills have upgraded since then.
As described above, v2 focuses on improving two interrelated objectives:
- Developer Experience (DX)
- Component variants via native CSS Variables
I came into v1 from old-school Jekyll and diluting with newfound React. Was also new to Astro.
One obvious difference is not enforcing index.ts
exports for every .astro
file. Unnecessary.
Stuff used to be littered everywhere. Big change was code co-location. For example, the index page has a few major sections, soj I extracted each section into individual files. They used to live all the way in src/components/block
— but were only used by one page. In cases like these, just co-locate these helper components like ./_components/ProjectsSection.astro
(taking advantage of how filepaths with _
don't get their own route). This has made maintainability so much more obvious.
And also just leaning into co-locating css <styles>
in .astro
files themselves, rather than atomic classnames.
I've extracted my writings about my past experience with and moving away from atomic-utility CSS, as it's not specific to this project. You can read about it at What’s Next After Atomic-Utility CSS | chuangcaleb.com.
During this v2, implementing CSS Variables (aka CSS Properties) (and some modern CSS tricks!) ties the methodology together.
Here are some shoutouts:
- argyleink/open-props: CSS custom properties to help accelerate adaptive and consistent design provides some zero-specificity standardised style tokens in CSS Variable form.
- Axiomatic CSS and Lobotomized Owls – A List Apart was REVOLUTIONARY for me for controlling (rather, letting go of control of!) flowing prose layout — see flow.css
- CSS Grid full-bleed layout tutorial — I struggled with full-bleed alternate-background-color sections
- Layout Breakouts with CSS Grid takes the above concept further with named grid lines for content layouts that breakout of the line-width. I may have gone overboard with SIX named breakpoints at cgrid.css
- utopia-core-scss by Utopia generates fluid-responsive CSS Variable tokens.
A color-[1-10]
color palette is neat, and it's easy to say "oh my button on hover needs to be one shade darker" — but then you've got some other elements which are slightly different shade on hover. We need semantic colors.
While tweaking my Obsidian, I found obsidian-minimal's color scheme implementation is great because it's (1) very extensible, (2) dynamically generated and (3) easy to use/great DX because of the semantic token names.
I'm still forming my adaptation of the color scheme system, the below is a WIP:
First, a particular theme defines --base-[hsl]
and --accent-[hsl]
.
--base-h: 234;
--base-s: 21%;
--base-l: 18%;
--accent-h: 9;
--accent-s: 80%;
--accent-l: 65%;
Then we build four types/classes of tokens, with n
number of color shades each:
--bg-[123]
- background (background)--ui-[123]
- border (background)--fg-[12349]
- text (foreground)--ax-[1234]
- accent (foreground)
As n
increases, emphasis decreases — except for:
- the last color shade of
bg
andui
colors, which are shades for being "active". - the last color shade of
fg
(fg-9
), which is the opposite-contrast foreground shade, for use like as text color on accent-background buttons, which is technically not least in "emphasis".
For each specific shade, it will take the hsl
segments and recompute a hsl
color by modifying the saturation
and lightness
segments. Some shades may opt to utilize hsla
and the opacity parameter.
--bg2: hsl(var(--base-h), calc(var(--base-s) - 2%), calc(var(--base-l) - 4%));
// tip: hue segment is always unmodified
Then we can map these to more semantic tokens, for example:
--border-primary: var(--ui1);
--border-secondary: var(--ui2);
--border-active: var(--ui3);
--text-strong: var(--fg1);
--text-normal: var(--fg2);
--text-faded: var(--fg3);
--text-muted: var(--fg4);
--text-on-accent: var(--bg2); // allow reusing base color shades
Finally, implementations can use the semantic tokens:
:where(a[href]) {
--color: var(--text-normal);
// can use base color shades directly instead of semantic tokens:
--color-underline: var(--ax3);
--color-hover: var(--text-strong);
color: var(--color);
text-decoration-color: var(--color-underline);
}
// psuedo-variant
:where(a[href]:is(:hover, :focus, :active)) {
--color-underline: var(--ax1);
color: var(--color-hover);
}
// variant just re-defines local CSS Variables/Properties
a.accent {
--color: var(--ax2);
--color-hover: var(--ax1)
}
Which is so much clearer, concise and expressive than atomic classnames. Very minimal CSS Variable calculations, negligible performance hits — in fact it's a smaller CSS bundle lol.
And, we can generate colors on the fly, even client-side! (Coming soon to stores near you!)
See dynamic-colors.scss.
Dynamic color generation is more complicated when handling light/dark modes. While obsidian-minimal implements unique base modifiers for each color shade for each theme/mode, I preferred automatically handle this.
Foreground elements with decreasing emphasis gets dimmer in dark mode, but brighter in light mode. Reverse is true with background colors.
To put it simply: a shade's lightness modifier must be flipped/inverted between dark and light mode. We use --m
as the light/dark Mode coefficient
- e.g.
--fg1: hsl( calc( var(--base-l) ${+/- n}% * var(--m) ) )
- is used to flip the +/-ve direction of lightness modifiers
- dark mode: 1 (fg emphasis gets lighter against dark bg)
- light mode: -1 (fg emphasis gets darker against light bg)
- can use decimal numbers, greater magnitude increases lightness-contrast between shades
After Lightness is flipped appropriately, there's another issue: accent colors are usually bright. Dark mode requires less contrast between accent shades than in light mode. One mode will always have the wrong degree of contrast between shades.
So just introduce a new variable lol, --a-l
as the Accent Lightness coefficient to work together with the Lightness coefficient, only for accent shades
- e.g.
--ax1: hsl( calc( var(--base-l) ${+/- n}% * var(--m) * var(--a-l) ) )
- is used to increase difference in Lightness between accent shades
- larger magnitudes for light mode with light bg, since accents also have high lightness
--bg2
is always a darker shade than--bg1
, so we just exclude passing--m
into--bg2
's shade calculation.--fg1
should always automatically be the lightest/darkest black/white color available — so just set the Lightness component to a 95-100% and let the--m
coefficient flip it according to light/dark mode.
I write my content locally in Obsidian and want to display them in the Astro site. I already version control with git, so I didn't need to reach for another remote cloud sync option but my public notes are mixed in with private ones. I don't want to expose all my personal journal notes lol. Just those files/notes in select folders/directories or passing some frontmatter condition.
One way would be to nest the obsidian vault repository within the Astro repository, and gitignore bad paths. But then that would load all Obsidian notes locally. A previous implementation was to nest the Astro repository at a public directory of the Obsidian vault repository.
But in the end, both ways will source control the content files, so I had a bunch of content-commits in between my source-code-commits. I no likey. I reached for some CMS solutions but that was over-engineering for now.
Currently, I make use of the kometenstaub/metadata-extractor plugin to dump the metadata cache of my entire Obsidian workspace, to a local .json
file. I run a custom script to process that metadata cache to filter out private notes and reorganize nested paths to root. A second script copies the processed list of markdown files and writes their new filepaths into an output directory. All this processing is gitignored
in my main Obsidian repo.
That output folder is synced to the cloud using Google Drive.
I had previously git-tracked this output directory and attached that repository as a submodule that would reside in this repo at src/content/obsidian-note
. It was simple and was my working solution for months... but (1a) version controlling non-source-code-data felt wrong, (2) I didn't want to look into safely running shell commands for stuff like git push
. (1b) I also intend to sync up my non-markdown assets like images.
Using Google Drive Desktop, I set my local output folder of markdown files for syncing up to the Drive.
I use the same Google Service Account from the guestbook feature to download the files with googleapis - npm to the src/content/obsidian-note/
folder. For implementation details, see [[lib/google/drive/download-folder.ts]].
It's an alright solution! This is another pledge to the Google overlords... but honestly there's no sensitive information. I just need a few MB's of cloud bucket storage, and this simple + free solution is easily better than provisioning an S2 bucket and all that.
The scripts are currently in my private repo, I can share it upon request.
Using Cloudinary, but just for project images. May change exactly how it works. See lib/cloudinary.
A cache step will use the Cloudinary API to get the results of all images in an asset folder and write to a local .json
file. Then when picking an image, we just read from this cache. The alternative would be to call the API in the .astro
frontmatter, but that would call the API on every refresh, and it would hit the limit.