[{"data":1,"prerenderedAt":2324},["ShallowReactive",2],{"latest-posts":3},[4,647],{"id":5,"title":6,"body":7,"date":635,"description":636,"extension":637,"meta":638,"navigation":639,"path":640,"rawbody":641,"seo":642,"stem":643,"tags":644,"__hash__":646},"blog\u002Fblog\u002Fgit-merge-vs-rebase.md","stop rebasing the past: merge vs rebase without lying to history",{"type":8,"value":9,"toc":618},"minimark",[10,14,17,20,28,31,65,68,73,76,81,110,117,120,126,130,154,162,173,183,187,193,201,204,207,213,220,226,229,232,238,244,250,260,264,348,355,359,362,367,370,376,379,382,385,391,398,416,419,425,428,434,438,449,455,458,462,471,481,487,493,499,503,506,527,534,540,544,574,577,581,586,589,592,595,614],[11,12,13],"p",{},"Every dev team has this argument sooner or later.",[11,15,16],{},"Someone wants a clean, linear history and reaches for rebase. Someone else insists rewriting history is dangerous and pushes for merge.",[11,18,19],{},"Both sides have a point — and they usually talk past each other, because \"rebase\" and \"merge\" mean different things in different contexts. The result is either a rigid rule nobody can fully explain (\"we always rebase\") or a free-for-all where the same repository ends up with three strategies and no one is quite sure what the history actually represents.",[11,21,22,23,27],{},"And it matters. That history is how you revert a bad release, track down a regression with ",[24,25,26],"code",{},"git bisect",", or explain to a colleague why a change landed the way it did. If the graph looks tidy but the commits no longer match what was ever built or tested, you've traded clarity for a story that's easier to read and harder to trust.",[11,29,30],{},"I've worked with merge-heavy, rebase-heavy and squash-heavy workflows. This post is the position I've settled on — not the only valid one, but one that keeps history accurate while still allowing clean branches:",[32,33,34,43,49,58],"ul",{},[35,36,37,38,42],"li",{},"Clean your branch locally with ",[39,40,41],"strong",{},"interactive rebase",".",[35,44,45,46,42],{},"Don't rewrite integration history with ",[39,47,48],{},"rebase onto main",[35,50,51,52,42],{},"Keep feature boundaries explicit with ",[39,53,54,57],{},[24,55,56],{},"--no-ff"," merges",[35,59,60,61,64],{},"Use ",[39,62,63],{},"squash merges"," sparingly — only if the branch will be deleted afterwards.",[11,66,67],{},"The rest of this explains why, and how to get there without the usual holy war.",[69,70,72],"h2",{"id":71},"there-are-two-kinds-of-rebase","there are two kinds of rebase",[11,74,75],{},"Most Git debates fall apart because \"rebase\" gets treated as one thing. It isn't. There are two fundamentally different uses, and almost every disagreement is really about which one someone has in mind.",[77,78,80],"h3",{"id":79},"_1-interactive-rebase-commit-cleanup","1. interactive rebase (commit cleanup)",[82,83,88],"pre",{"className":84,"code":85,"language":86,"meta":87,"style":87},"language-bash shiki shiki-themes github-light github-dark","git rebase -i origin\u002Fmain\n","bash","",[24,89,90],{"__ignoreMap":87},[91,92,95,99,103,107],"span",{"class":93,"line":94},"line",1,[91,96,98],{"class":97},"sScJk","git",[91,100,102],{"class":101},"sZZnC"," rebase",[91,104,106],{"class":105},"sj4cs"," -i",[91,108,109],{"class":101}," origin\u002Fmain\n",[11,111,112,113,116],{},"Reword commits, squash fixups, reorder changes, clean up your branch. This edits the shape of your branch ",[39,114,115],{},"without changing its base"," — you're polishing your own history before sharing it.",[11,118,119],{},"The rule: only do this before the branch is shared. Rewriting unpublished history is safe. Rewriting shared history is not.",[121,122,123],"blockquote",{},[11,124,125],{},"Interactive rebase is a cleanup tool — not an integration strategy.",[77,127,129],{"id":128},"_2-integration-rebase-rebasing-onto-main","2. integration rebase (rebasing onto main)",[82,131,133],{"className":84,"code":132,"language":86,"meta":87,"style":87},"git fetch origin\ngit rebase origin\u002Fmain\n",[24,134,135,145],{"__ignoreMap":87},[91,136,137,139,142],{"class":93,"line":94},[91,138,98],{"class":97},[91,140,141],{"class":101}," fetch",[91,143,144],{"class":101}," origin\n",[91,146,148,150,152],{"class":93,"line":147},2,[91,149,98],{"class":97},[91,151,102],{"class":101},[91,153,109],{"class":101},[11,155,156,157,161],{},"This replays your commits on top of a new base. If conflicts occur, you resolve them ",[158,159,160],"em",{},"while"," replaying history. That can produce commits which:",[32,163,164,167,170],{},[35,165,166],{},"never existed in that form,",[35,168,169],{},"were never developed against that base,",[35,171,172],{},"and contain conflict resolutions baked into rewritten commits.",[11,174,175,176,179,180,182],{},"Afterwards, history claims: ",[158,177,178],{},"\"these commits were developed on top of this base.\""," But they weren't. It's a cleaner story, not a literal record. ",[24,181,26],{}," can land on a commit that never truly existed — so \"the bug was introduced here\" may point at a synthetic state.",[69,184,186],{"id":185},"seeing-the-difference","seeing the difference",[11,188,189,190,192],{},"A ",[24,191,56],{}," merge keeps the feature branch as it was:",[82,194,199],{"className":195,"code":197,"language":198},[196],"language-text","main:     A---B-------M\n               \\     \u002F\n                C---D   (feature)\n","text",[24,200,197],{"__ignoreMap":87},[11,202,203],{},"C and D remain exactly as developed, M marks the integration point, and the history reflects reality.",[11,205,206],{},"An integration rebase rewrites them. Before:",[82,208,211],{"className":209,"code":210,"language":198},[196],"main:     A---B\nfeature:       C---D\n",[24,212,210],{"__ignoreMap":87},[11,214,215,216,219],{},"After ",[24,217,218],{},"git rebase main",":",[82,221,224],{"className":222,"code":223,"language":198},[196],"main:     A---B---C'---D'\n",[24,225,223],{"__ignoreMap":87},[11,227,228],{},"C and D became C' and D'. Conflict resolutions may be embedded in them, and history now implies they were developed on top of B.",[11,230,231],{},"Interactive rebase looks superficially similar but does something else entirely. Before:",[82,233,236],{"className":234,"code":235,"language":198},[196],"A---B---C---D---E\n",[24,237,235],{"__ignoreMap":87},[11,239,215,240,243],{},[24,241,242],{},"git rebase -i"," (squashing D and E into C):",[82,245,248],{"className":246,"code":247,"language":198},[196],"A---B---C'\n",[24,249,247],{"__ignoreMap":87},[11,251,252,253,256,257,42],{},"No base change, no upstream integration — just cleanup. That's history ",[158,254,255],{},"editing",", not integration ",[158,258,259],{},"rewriting",[69,261,263],{"id":262},"the-strategies-compared","the strategies, compared",[265,266,267,286],"table",{},[268,269,270],"thead",{},[271,272,273,277,280,283],"tr",{},[274,275,276],"th",{},"Strategy",[274,278,279],{},"What it does",[274,281,282],{},"Pros",[274,284,285],{},"Cons",[287,288,289,304,320,334],"tbody",{},[271,290,291,295,298,301],{},[292,293,294],"td",{},"Fast-forward merge",[292,296,297],{},"Moves the branch pointer forward",[292,299,300],{},"Clean linear history",[292,302,303],{},"Loses the feature boundary",[271,305,306,311,314,317],{},[292,307,308,309],{},"Merge ",[24,310,56],{},[292,312,313],{},"Creates an explicit merge commit",[292,315,316],{},"Clear feature grouping, easy revert",[292,318,319],{},"Extra merge commits",[271,321,322,325,328,331],{},[292,323,324],{},"Rebase and merge",[292,326,327],{},"Rewrites the branch onto main, then fast-forwards",[292,329,330],{},"Linear history, no merge commits",[292,332,333],{},"Rewrites integration history",[271,335,336,339,342,345],{},[292,337,338],{},"Squash merge",[292,340,341],{},"Collapses the branch into one commit",[292,343,344],{},"Very clean main",[292,346,347],{},"Original commits not marked merged",[11,349,350,351,354],{},"Note that interactive rebase isn't in the table. It's not an integration strategy — it's a local cleanup tool used ",[158,352,353],{},"before"," integration.",[69,356,358],{"id":357},"but-what-about-a-clean-linear-history","but what about a clean linear history?",[11,360,361],{},"The main argument for integration rebasing is that linear history is easier to read. Visually, this:",[82,363,365],{"className":364,"code":235,"language":198},[196],[24,366,235],{"__ignoreMap":87},[11,368,369],{},"is simpler than this:",[82,371,374],{"className":372,"code":373,"language":198},[196],"A---B-------M\n     \\     \u002F\n      C---D\n",[24,375,373],{"__ignoreMap":87},[11,377,378],{},"No branches, no merge commits, just a straight line. Some teams happily take that trade: a single line of commits is easier to scan, especially if they rarely inspect individual commits or use bisect.",[11,380,381],{},"But linear history has a cost. Feature boundaries disappear. Integration points vanish. Commits get rewritten to fit a base they weren't developed on, and conflict resolutions get baked into them.",[11,383,384],{},"Linear history optimizes for visual simplicity. Merge history optimizes for structural clarity and historical accuracy. If you live in the GitHub or GitLab UI, topology may not matter much. If you explore history via Git itself, debug regressions, or analyze how an architecture moved over time, it matters a lot. You have to choose what you value.",[69,386,388,389],{"id":387},"why-i-prefer-no-ff","why I prefer ",[24,390,56],{},[11,392,393,394,397],{},"If features always go through merge requests and never directly into ",[24,395,396],{},"main",", then:",[82,399,401],{"className":84,"code":400,"language":86,"meta":87,"style":87},"git merge --no-ff feature-branch\n",[24,402,403],{"__ignoreMap":87},[91,404,405,407,410,413],{"class":93,"line":94},[91,406,98],{"class":97},[91,408,409],{"class":101}," merge",[91,411,412],{"class":105}," --no-ff",[91,414,415],{"class":101}," feature-branch\n",[11,417,418],{},"produces:",[82,420,423],{"className":421,"code":422,"language":198},[196],"*   Merge branch 'feature-x'\n|\\\n| * commit 3\n| * commit 2\n| * commit 1\n|\u002F\n* previous main\n",[24,424,422],{"__ignoreMap":87},[11,426,427],{},"Clear feature grouping, real integration markers, easy feature-level reverts, and a graph that reflects how the work actually happened. Merge commits become the table of contents of your history.",[11,429,430,431,42],{},"Fast-forward, by contrast, removes the branch boundary entirely — you lose the grouping, the integration moment, and any structure in ",[24,432,433],{},"git log --graph",[69,435,437],{"id":436},"squash-simplifying-at-the-cost-of-history","squash: simplifying at the cost of history",[11,439,440,441,444,445,448],{},"Squash turns a branch into one commit — ",[24,442,443],{},"A---B---C"," becomes ",[24,446,447],{},"A---S",". It's useful for small features, noisy branches, and cases where the intermediate history genuinely doesn't matter.",[11,450,451,452,454],{},"But Git does not mark the original commits as merged. The feature branch still contains its history, future merges from it get confusing, and you lose commit-level visibility in ",[24,453,396],{},". So: only squash if you delete the feature branch immediately, locally and remotely.",[11,456,457],{},"Squash isn't more powerful than interactive rebase. It's just collapsing, done at integration time.",[69,459,461],{"id":460},"keeping-history-clean-anyway","keeping history clean anyway",[11,463,464,465,467,468,470],{},"If you use ",[24,466,56],{},", messy commits will land in ",[24,469,396],{}," unless you do something about it. Three options, best used together:",[11,472,473,476,477,480],{},[39,474,475],{},"Clean before sharing."," ",[24,478,479],{},"git rebase -i origin\u002Fmain"," — squash, reword, refine — before you push.",[11,482,483,486],{},[39,484,485],{},"Make merge commits the curated layer."," Write merge messages that carry the story:",[82,488,491],{"className":489,"code":490,"language":198},[196],"feat: add billing dashboard (MR !1234)\n\n- Invoice overview\n- Filtering\n- New API endpoint\n",[24,492,490],{"__ignoreMap":87},[11,494,495,498],{},[39,496,497],{},"Enforce commit discipline."," No \"fix\" commits, no \"wip\" commits, meaningful messages. Cultural, but effective.",[69,500,502],{"id":501},"catching-conflicts-without-rebasing","catching conflicts without rebasing",[11,504,505],{},"You don't need integration rebase to stay up to date:",[82,507,509],{"className":84,"code":508,"language":86,"meta":87,"style":87},"git fetch origin\ngit merge origin\u002Fmain\n",[24,510,511,519],{"__ignoreMap":87},[91,512,513,515,517],{"class":93,"line":94},[91,514,98],{"class":97},[91,516,141],{"class":101},[91,518,144],{"class":101},[91,520,521,523,525],{"class":93,"line":147},[91,522,98],{"class":97},[91,524,409],{"class":101},[91,526,109],{"class":101},[11,528,529,530,533],{},"Do this ",[39,531,532],{},"before opening or updating your pull request",", so the merge result is what gets reviewed and tested. It surfaces conflicts early, preserves real integration states, and rewrites nothing. Require CI on updated branches or merge results and you get the safety without the rewriting.",[11,535,536,537,539],{},"Both GitHub and GitLab can enforce this: require merge commits (disable \"squash and merge\" or \"rebase and merge\" if you prefer), and protect ",[24,538,396],{}," so force-pushes are blocked.",[69,541,543],{"id":542},"the-workflow-i-recommend","the workflow I recommend",[545,546,547,552,555,560,563,568,571],"ol",{},[35,548,549,550,42],{},"No direct pushes to ",[24,551,396],{},[35,553,554],{},"Use merge requests.",[35,556,557,558,42],{},"Prefer ",[24,559,56],{},[35,561,562],{},"Use interactive rebase only before sharing.",[35,564,565,566,42],{},"Avoid integration rebasing onto ",[24,567,396],{},[35,569,570],{},"Only squash if you're deleting the branch.",[35,572,573],{},"Require CI on merge results.",[11,575,576],{},"That preserves clarity without rewriting the past.",[69,578,580],{"id":579},"final-thoughts","final thoughts",[121,582,583],{},[11,584,585],{},"Interactive rebase is a scalpel. Integration rebase is time travel.",[11,587,588],{},"One cleans up your work. The other rewrites how it appears to have happened.",[11,590,591],{},"Plenty of experienced engineers prefer rebasing, and there are smart teams on both sides. For me the deciding factor is simple: I value structural clarity and historical accuracy over visual linearity. That's a trade-off, not a universal rule. If your team prefers a linear history and accepts rewriting integration commits, that can work — as long as you understand what it costs.",[11,593,594],{},"So I'm curious: do you optimize for a straight line, or for a faithful record of how things evolved?",[11,596,597,476,600,607,608,613],{},[39,598,599],{},"Further reading:",[601,602,606],"a",{"href":603,"rel":604},"https:\u002F\u002Fgit-scm.com\u002Fbook\u002Fen\u002Fv2\u002FGit-Branching-Basic-Branching-and-Merging",[605],"nofollow","Git — Branching and Merging"," and ",[601,609,612],{"href":610,"rel":611},"https:\u002F\u002Fgit-scm.com\u002Fbook\u002Fen\u002Fv2\u002FGit-Branching-Rebasing",[605],"Git — Rebasing"," in the official Git documentation.",[615,616,617],"style",{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":87,"searchDepth":147,"depth":147,"links":619},[620,625,626,627,628,630,631,632,633,634],{"id":71,"depth":147,"text":72,"children":621},[622,624],{"id":79,"depth":623,"text":80},3,{"id":128,"depth":623,"text":129},{"id":185,"depth":147,"text":186},{"id":262,"depth":147,"text":263},{"id":357,"depth":147,"text":358},{"id":387,"depth":147,"text":629},"why I prefer --no-ff",{"id":436,"depth":147,"text":437},{"id":460,"depth":147,"text":461},{"id":501,"depth":147,"text":502},{"id":542,"depth":147,"text":543},{"id":579,"depth":147,"text":580},"2026-07-15","interactive rebase is a scalpel. integration rebase is time travel. the merge strategy I settled on, and why I want history to stay a record rather than a story.","md",{},true,"\u002Fblog\u002Fgit-merge-vs-rebase","---\ntitle: 'stop rebasing the past: merge vs rebase without lying to history'\ndate: '2026-07-15'\ndescription: 'interactive rebase is a scalpel. integration rebase is time travel. the merge strategy I settled on, and why I want history to stay a record rather than a story.'\ntags: ['git', 'workflow']\n---\n\nEvery dev team has this argument sooner or later.\n\nSomeone wants a clean, linear history and reaches for rebase. Someone else insists rewriting history is dangerous and pushes for merge.\n\nBoth sides have a point — and they usually talk past each other, because \"rebase\" and \"merge\" mean different things in different contexts. The result is either a rigid rule nobody can fully explain (\"we always rebase\") or a free-for-all where the same repository ends up with three strategies and no one is quite sure what the history actually represents.\n\nAnd it matters. That history is how you revert a bad release, track down a regression with `git bisect`, or explain to a colleague why a change landed the way it did. If the graph looks tidy but the commits no longer match what was ever built or tested, you've traded clarity for a story that's easier to read and harder to trust.\n\nI've worked with merge-heavy, rebase-heavy and squash-heavy workflows. This post is the position I've settled on — not the only valid one, but one that keeps history accurate while still allowing clean branches:\n\n- Clean your branch locally with **interactive rebase**.\n- Don't rewrite integration history with **rebase onto main**.\n- Keep feature boundaries explicit with **`--no-ff` merges**.\n- Use **squash merges** sparingly — only if the branch will be deleted afterwards.\n\nThe rest of this explains why, and how to get there without the usual holy war.\n\n## there are two kinds of rebase\n\nMost Git debates fall apart because \"rebase\" gets treated as one thing. It isn't. There are two fundamentally different uses, and almost every disagreement is really about which one someone has in mind.\n\n### 1. interactive rebase (commit cleanup)\n\n```bash\ngit rebase -i origin\u002Fmain\n```\n\nReword commits, squash fixups, reorder changes, clean up your branch. This edits the shape of your branch **without changing its base** — you're polishing your own history before sharing it.\n\nThe rule: only do this before the branch is shared. Rewriting unpublished history is safe. Rewriting shared history is not.\n\n> Interactive rebase is a cleanup tool — not an integration strategy.\n\n### 2. integration rebase (rebasing onto main)\n\n```bash\ngit fetch origin\ngit rebase origin\u002Fmain\n```\n\nThis replays your commits on top of a new base. If conflicts occur, you resolve them *while* replaying history. That can produce commits which:\n\n- never existed in that form,\n- were never developed against that base,\n- and contain conflict resolutions baked into rewritten commits.\n\nAfterwards, history claims: *\"these commits were developed on top of this base.\"* But they weren't. It's a cleaner story, not a literal record. `git bisect` can land on a commit that never truly existed — so \"the bug was introduced here\" may point at a synthetic state.\n\n## seeing the difference\n\nA `--no-ff` merge keeps the feature branch as it was:\n\n```\nmain:     A---B-------M\n               \\     \u002F\n                C---D   (feature)\n```\n\nC and D remain exactly as developed, M marks the integration point, and the history reflects reality.\n\nAn integration rebase rewrites them. Before:\n\n```\nmain:     A---B\nfeature:       C---D\n```\n\nAfter `git rebase main`:\n\n```\nmain:     A---B---C'---D'\n```\n\nC and D became C' and D'. Conflict resolutions may be embedded in them, and history now implies they were developed on top of B.\n\nInteractive rebase looks superficially similar but does something else entirely. Before:\n\n```\nA---B---C---D---E\n```\n\nAfter `git rebase -i` (squashing D and E into C):\n\n```\nA---B---C'\n```\n\nNo base change, no upstream integration — just cleanup. That's history *editing*, not integration *rewriting*.\n\n## the strategies, compared\n\n| Strategy | What it does | Pros | Cons |\n|---|---|---|---|\n| Fast-forward merge | Moves the branch pointer forward | Clean linear history | Loses the feature boundary |\n| Merge `--no-ff` | Creates an explicit merge commit | Clear feature grouping, easy revert | Extra merge commits |\n| Rebase and merge | Rewrites the branch onto main, then fast-forwards | Linear history, no merge commits | Rewrites integration history |\n| Squash merge | Collapses the branch into one commit | Very clean main | Original commits not marked merged |\n\nNote that interactive rebase isn't in the table. It's not an integration strategy — it's a local cleanup tool used *before* integration.\n\n## but what about a clean linear history?\n\nThe main argument for integration rebasing is that linear history is easier to read. Visually, this:\n\n```\nA---B---C---D---E\n```\n\nis simpler than this:\n\n```\nA---B-------M\n     \\     \u002F\n      C---D\n```\n\nNo branches, no merge commits, just a straight line. Some teams happily take that trade: a single line of commits is easier to scan, especially if they rarely inspect individual commits or use bisect.\n\nBut linear history has a cost. Feature boundaries disappear. Integration points vanish. Commits get rewritten to fit a base they weren't developed on, and conflict resolutions get baked into them.\n\nLinear history optimizes for visual simplicity. Merge history optimizes for structural clarity and historical accuracy. If you live in the GitHub or GitLab UI, topology may not matter much. If you explore history via Git itself, debug regressions, or analyze how an architecture moved over time, it matters a lot. You have to choose what you value.\n\n## why I prefer `--no-ff`\n\nIf features always go through merge requests and never directly into `main`, then:\n\n```bash\ngit merge --no-ff feature-branch\n```\n\nproduces:\n\n```\n*   Merge branch 'feature-x'\n|\\\n| * commit 3\n| * commit 2\n| * commit 1\n|\u002F\n* previous main\n```\n\nClear feature grouping, real integration markers, easy feature-level reverts, and a graph that reflects how the work actually happened. Merge commits become the table of contents of your history.\n\nFast-forward, by contrast, removes the branch boundary entirely — you lose the grouping, the integration moment, and any structure in `git log --graph`.\n\n## squash: simplifying at the cost of history\n\nSquash turns a branch into one commit — `A---B---C` becomes `A---S`. It's useful for small features, noisy branches, and cases where the intermediate history genuinely doesn't matter.\n\nBut Git does not mark the original commits as merged. The feature branch still contains its history, future merges from it get confusing, and you lose commit-level visibility in `main`. So: only squash if you delete the feature branch immediately, locally and remotely.\n\nSquash isn't more powerful than interactive rebase. It's just collapsing, done at integration time.\n\n## keeping history clean anyway\n\nIf you use `--no-ff`, messy commits will land in `main` unless you do something about it. Three options, best used together:\n\n**Clean before sharing.** `git rebase -i origin\u002Fmain` — squash, reword, refine — before you push.\n\n**Make merge commits the curated layer.** Write merge messages that carry the story:\n\n```\nfeat: add billing dashboard (MR !1234)\n\n- Invoice overview\n- Filtering\n- New API endpoint\n```\n\n**Enforce commit discipline.** No \"fix\" commits, no \"wip\" commits, meaningful messages. Cultural, but effective.\n\n## catching conflicts without rebasing\n\nYou don't need integration rebase to stay up to date:\n\n```bash\ngit fetch origin\ngit merge origin\u002Fmain\n```\n\nDo this **before opening or updating your pull request**, so the merge result is what gets reviewed and tested. It surfaces conflicts early, preserves real integration states, and rewrites nothing. Require CI on updated branches or merge results and you get the safety without the rewriting.\n\nBoth GitHub and GitLab can enforce this: require merge commits (disable \"squash and merge\" or \"rebase and merge\" if you prefer), and protect `main` so force-pushes are blocked.\n\n## the workflow I recommend\n\n1. No direct pushes to `main`.\n2. Use merge requests.\n3. Prefer `--no-ff`.\n4. Use interactive rebase only before sharing.\n5. Avoid integration rebasing onto `main`.\n6. Only squash if you're deleting the branch.\n7. Require CI on merge results.\n\nThat preserves clarity without rewriting the past.\n\n## final thoughts\n\n> Interactive rebase is a scalpel. Integration rebase is time travel.\n\nOne cleans up your work. The other rewrites how it appears to have happened.\n\nPlenty of experienced engineers prefer rebasing, and there are smart teams on both sides. For me the deciding factor is simple: I value structural clarity and historical accuracy over visual linearity. That's a trade-off, not a universal rule. If your team prefers a linear history and accepts rewriting integration commits, that can work — as long as you understand what it costs.\n\nSo I'm curious: do you optimize for a straight line, or for a faithful record of how things evolved?\n\n**Further reading:** [Git — Branching and Merging](https:\u002F\u002Fgit-scm.com\u002Fbook\u002Fen\u002Fv2\u002FGit-Branching-Basic-Branching-and-Merging) and [Git — Rebasing](https:\u002F\u002Fgit-scm.com\u002Fbook\u002Fen\u002Fv2\u002FGit-Branching-Rebasing) in the official Git documentation.\n",{"title":6,"description":636},"blog\u002Fgit-merge-vs-rebase",[98,645],"workflow","31O9uEwGZNsuziy21IGOv-vWzG2kX4h7lzBaC1p8aiU",{"id":648,"title":649,"body":650,"date":2311,"description":2312,"extension":637,"meta":2313,"navigation":639,"path":2314,"rawbody":2315,"seo":2316,"stem":2317,"tags":2318,"__hash__":2323},"blog\u002Fblog\u002Frebuilding-this-site.md","rebuilding my portfolio after 4 years of \"coming soon(ish)\"",{"type":8,"value":651,"toc":2301},[652,658,661,664,668,683,750,765,772,776,783,920,927,946,950,961,964,1031,1042,1206,1209,1216,1293,1308,1312,1327,1330,1472,1479,1482,1541,1548,1577,1581,1602,1605,1627,1641,1712,1719,1723,1740,1751,2203,2210,2226,2229,2240,2244,2255,2269,2280,2284,2295,2298],[11,653,654,655,42],{},"For four years, my personal website consisted of a hero, six project cards, and the sentence ",[158,656,657],{},"\"My personal slice of the web is still under construction and will be live if I can find some time to finish it ;)\"",[11,659,660],{},"I never found the time. So instead of finishing v1, I deleted it.",[11,662,663],{},"What replaced it is less a portfolio than a playground: a terminal you can actually use, a pretend operating system with draggable windows, Conway's Game of Life running in four places off one engine, and a service worker that can show you this site as it looked six months ago. This post is the tour, and the handful of ideas that kept all of it from collapsing under its own cuteness.",[69,665,667],{"id":666},"the-stack","the stack",[11,669,670,671,676,677,682],{},"The rebuild is ",[601,672,675],{"href":673,"rel":674},"https:\u002F\u002Fnuxt.com",[605],"Nuxt 4"," + TypeScript + ",[601,678,681],{"href":679,"rel":680},"https:\u002F\u002Fbulma.io",[605],"Bulma 1",", generated as a fully static site. Bulma might seem like an odd choice next to the utility-CSS crowd, but v1 moved everything to CSS variables, which makes theming almost free:",[82,684,688],{"className":685,"code":686,"language":687,"meta":87,"style":87},"language-scss shiki shiki-themes github-light github-dark","@use \"bulma\u002Fsass\" with (\n  $family-primary: '\"Inter\", sans-serif',\n  $family-code: '\"JetBrains Mono\", monospace',\n  $primary: #ffba00\n);\n","scss",[24,689,690,706,721,733,744],{"__ignoreMap":87},[91,691,692,696,699,702],{"class":93,"line":94},[91,693,695],{"class":694},"szBVR","@use",[91,697,698],{"class":101}," \"bulma\u002Fsass\"",[91,700,701],{"class":694}," with",[91,703,705],{"class":704},"sVt8B"," (\n",[91,707,708,712,715,718],{"class":93,"line":147},[91,709,711],{"class":710},"s4XuR","  $family-primary",[91,713,714],{"class":704},": ",[91,716,717],{"class":101},"'\"Inter\", sans-serif'",[91,719,720],{"class":704},",\n",[91,722,723,726,728,731],{"class":93,"line":623},[91,724,725],{"class":710},"  $family-code",[91,727,714],{"class":704},[91,729,730],{"class":101},"'\"JetBrains Mono\", monospace'",[91,732,720],{"class":704},[91,734,736,739,741],{"class":93,"line":735},4,[91,737,738],{"class":710},"  $primary",[91,740,714],{"class":704},[91,742,743],{"class":105},"#ffba00\n",[91,745,747],{"class":93,"line":746},5,[91,748,749],{"class":704},");\n",[11,751,752,753,756,757,760,761,764],{},"Dark mode is just ",[24,754,755],{},"data-theme=\"dark\""," on the ",[24,758,759],{},"\u003Chtml>"," element — Bulma swaps every variable, and anything custom reads ",[24,762,763],{},"var(--bulma-*)"," so it follows along. Even the canvas animations read their colours from the CSS variables at runtime, which is why the amber cells in the hero know they're in dark mode.",[11,766,767,768,771],{},"Everything else follows from one constraint I set early: ",[39,769,770],{},"the site must stay statically generated",". No runtime server, no API I have to keep alive. Anything dynamic is either client-side or an opt-in external service. That constraint turned out to be a feature — it forced most of the \"backend\" ideas below to become honest little client-side systems instead.",[69,773,775],{"id":774},"the-terminal","the terminal",[11,777,778,779,782],{},"The gimmick I actually wanted to build: press ",[24,780,781],{},"~"," and you get a terminal. It's not an emulator — it's a Vue component and a composable with a command map:",[82,784,788],{"className":785,"code":786,"language":787,"meta":87,"style":87},"language-ts shiki shiki-themes github-light github-dark","const commands: Record\u003Cstring, TerminalCommand> = {\n  help:     { description: 'List available commands', exec: () => ... },\n  projects: { description: 'List projects', exec: (args) => ... },\n  cd:       { description: 'Go to a page', exec: (args) => navigate(args[0]) },\n  \u002F\u002F ...50 or so more\n}\n","ts",[24,789,790,824,849,876,908,914],{"__ignoreMap":87},[91,791,792,795,798,800,803,806,809,812,815,818,821],{"class":93,"line":94},[91,793,794],{"class":694},"const",[91,796,797],{"class":105}," commands",[91,799,219],{"class":694},[91,801,802],{"class":97}," Record",[91,804,805],{"class":704},"\u003C",[91,807,808],{"class":105},"string",[91,810,811],{"class":704},", ",[91,813,814],{"class":97},"TerminalCommand",[91,816,817],{"class":704},"> ",[91,819,820],{"class":694},"=",[91,822,823],{"class":704}," {\n",[91,825,826,829,832,834,837,840,843,846],{"class":93,"line":147},[91,827,828],{"class":704},"  help:     { description: ",[91,830,831],{"class":101},"'List available commands'",[91,833,811],{"class":704},[91,835,836],{"class":97},"exec",[91,838,839],{"class":704},": () ",[91,841,842],{"class":694},"=>",[91,844,845],{"class":694}," ...",[91,847,848],{"class":704}," },\n",[91,850,851,854,857,859,861,864,867,870,872,874],{"class":93,"line":623},[91,852,853],{"class":704},"  projects: { description: ",[91,855,856],{"class":101},"'List projects'",[91,858,811],{"class":704},[91,860,836],{"class":97},[91,862,863],{"class":704},": (",[91,865,866],{"class":710},"args",[91,868,869],{"class":704},") ",[91,871,842],{"class":694},[91,873,845],{"class":694},[91,875,848],{"class":704},[91,877,878,881,884,886,888,890,892,894,896,899,902,905],{"class":93,"line":735},[91,879,880],{"class":704},"  cd:       { description: ",[91,882,883],{"class":101},"'Go to a page'",[91,885,811],{"class":704},[91,887,836],{"class":97},[91,889,863],{"class":704},[91,891,866],{"class":710},[91,893,869],{"class":704},[91,895,842],{"class":694},[91,897,898],{"class":97}," navigate",[91,900,901],{"class":704},"(args[",[91,903,904],{"class":105},"0",[91,906,907],{"class":704},"]) },\n",[91,909,910],{"class":93,"line":746},[91,911,913],{"class":912},"sJ8bj","  \u002F\u002F ...50 or so more\n",[91,915,917],{"class":93,"line":916},6,[91,918,919],{"class":704},"}\n",[11,921,922,923,926],{},"State lives in ",[24,924,925],{},"useState",", so the navbar button, the footer link and the overlay all talk to the same terminal instance rather than three lookalikes. Commands can navigate the router, toggle the theme, fetch the GitHub API, or take over the keyboard entirely.",[11,928,929,930,933,934,937,938,941,942,945],{},"The rule that keeps it from being a toy: it reads the same data the pages do. ",[24,931,932],{},"about"," prints the same profile the ",[24,935,936],{},"\u002Fabout"," page renders; ",[24,939,940],{},"blog"," lists the same posts the ",[24,943,944],{},"\u002Fblog"," index does. There's no second copy of me to drift out of date.",[69,947,949],{"id":948},"a-game-is-a-string-factory","a game is a string factory",[11,951,952,953,956,957,960],{},"Taking over the keyboard is how ",[24,954,955],{},"snake"," ended up in there. The fun constraint: the \"screen\" is just a ",[24,958,959],{},"\u003Cpre>"," element. No canvas, no WebGL — monospace text re-rendered eight times a second.",[11,962,963],{},"A game is a function handed two callbacks, one to draw a frame and one to report the final output:",[82,965,967],{"className":785,"code":966,"language":787,"meta":87,"style":87},"export interface GameCallbacks {\n  onFrame(frame: string): void\n  onEnd(lines: string[]): void\n}\n",[24,968,969,982,1006,1027],{"__ignoreMap":87},[91,970,971,974,977,980],{"class":93,"line":94},[91,972,973],{"class":694},"export",[91,975,976],{"class":694}," interface",[91,978,979],{"class":97}," GameCallbacks",[91,981,823],{"class":704},[91,983,984,987,990,993,995,998,1001,1003],{"class":93,"line":147},[91,985,986],{"class":97},"  onFrame",[91,988,989],{"class":704},"(",[91,991,992],{"class":710},"frame",[91,994,219],{"class":694},[91,996,997],{"class":105}," string",[91,999,1000],{"class":704},")",[91,1002,219],{"class":694},[91,1004,1005],{"class":105}," void\n",[91,1007,1008,1011,1013,1016,1018,1020,1023,1025],{"class":93,"line":623},[91,1009,1010],{"class":97},"  onEnd",[91,1012,989],{"class":704},[91,1014,1015],{"class":710},"lines",[91,1017,219],{"class":694},[91,1019,997],{"class":105},[91,1021,1022],{"class":704},"[])",[91,1024,219],{"class":694},[91,1026,1005],{"class":105},[91,1028,1029],{"class":93,"line":735},[91,1030,919],{"class":704},[11,1032,1033,1034,1037,1038,1041],{},"Every tick, snake builds the entire board as a string — box-drawing characters for the walls, ",[24,1035,1036],{},"█"," for the body, ",[24,1039,1040],{},"●"," for the food — and hands it over. Because the font is monospace, the \"pixels\" line up:",[82,1043,1045],{"className":785,"code":1044,"language":787,"meta":87,"style":87},"function render() {\n  const grid = buildGrid()          \u002F\u002F 26 × 12 characters\n  grid[food.y][food.x] = '●'\n  snake.forEach((cell, i) => {\n    grid[cell.y][cell.x] = i === 0 ? '▓' : '█'\n  })\n  onFrame(header + grid.map((row) => row.join(' ')).join('\\n'))\n}\n",[24,1046,1047,1058,1078,1088,1113,1141,1146,1201],{"__ignoreMap":87},[91,1048,1049,1052,1055],{"class":93,"line":94},[91,1050,1051],{"class":694},"function",[91,1053,1054],{"class":97}," render",[91,1056,1057],{"class":704},"() {\n",[91,1059,1060,1063,1066,1069,1072,1075],{"class":93,"line":147},[91,1061,1062],{"class":694},"  const",[91,1064,1065],{"class":105}," grid",[91,1067,1068],{"class":694}," =",[91,1070,1071],{"class":97}," buildGrid",[91,1073,1074],{"class":704},"()          ",[91,1076,1077],{"class":912},"\u002F\u002F 26 × 12 characters\n",[91,1079,1080,1083,1085],{"class":93,"line":623},[91,1081,1082],{"class":704},"  grid[food.y][food.x] ",[91,1084,820],{"class":694},[91,1086,1087],{"class":101}," '●'\n",[91,1089,1090,1093,1096,1099,1102,1104,1107,1109,1111],{"class":93,"line":735},[91,1091,1092],{"class":704},"  snake.",[91,1094,1095],{"class":97},"forEach",[91,1097,1098],{"class":704},"((",[91,1100,1101],{"class":710},"cell",[91,1103,811],{"class":704},[91,1105,1106],{"class":710},"i",[91,1108,869],{"class":704},[91,1110,842],{"class":694},[91,1112,823],{"class":704},[91,1114,1115,1118,1120,1123,1126,1129,1132,1135,1138],{"class":93,"line":746},[91,1116,1117],{"class":704},"    grid[cell.y][cell.x] ",[91,1119,820],{"class":694},[91,1121,1122],{"class":704}," i ",[91,1124,1125],{"class":694},"===",[91,1127,1128],{"class":105}," 0",[91,1130,1131],{"class":694}," ?",[91,1133,1134],{"class":101}," '▓'",[91,1136,1137],{"class":694}," :",[91,1139,1140],{"class":101}," '█'\n",[91,1142,1143],{"class":93,"line":916},[91,1144,1145],{"class":704},"  })\n",[91,1147,1149,1151,1154,1157,1160,1163,1165,1168,1170,1172,1175,1178,1180,1183,1186,1188,1190,1193,1196,1198],{"class":93,"line":1148},7,[91,1150,986],{"class":97},[91,1152,1153],{"class":704},"(header ",[91,1155,1156],{"class":694},"+",[91,1158,1159],{"class":704}," grid.",[91,1161,1162],{"class":97},"map",[91,1164,1098],{"class":704},[91,1166,1167],{"class":710},"row",[91,1169,869],{"class":704},[91,1171,842],{"class":694},[91,1173,1174],{"class":704}," row.",[91,1176,1177],{"class":97},"join",[91,1179,989],{"class":704},[91,1181,1182],{"class":101},"' '",[91,1184,1185],{"class":704},")).",[91,1187,1177],{"class":97},[91,1189,989],{"class":704},[91,1191,1192],{"class":101},"'",[91,1194,1195],{"class":105},"\\n",[91,1197,1192],{"class":101},[91,1199,1200],{"class":704},"))\n",[91,1202,1204],{"class":93,"line":1203},8,[91,1205,919],{"class":704},[11,1207,1208],{},"Diffing a full-frame string sounds wasteful, but the frame is ~700 characters. The DOM update is nothing; your GPU yawns.",[11,1210,1211,1212,1215],{},"Stealing the keyboard is the other half. The terminal normally routes keys to an ",[24,1213,1214],{},"\u003Cinput>","; while a game is active, the input is hidden and a window-level listener feeds keys to the game:",[82,1217,1219],{"className":785,"code":1218,"language":787,"meta":87,"style":87},"useEventListener('keydown', (event) => {\n  if (!isOpen.value || !activeGame.value) return\n  if (activeGame.value.onKey(event.key)) event.preventDefault()\n})\n",[24,1220,1221,1243,1269,1288],{"__ignoreMap":87},[91,1222,1223,1226,1228,1231,1234,1237,1239,1241],{"class":93,"line":94},[91,1224,1225],{"class":97},"useEventListener",[91,1227,989],{"class":704},[91,1229,1230],{"class":101},"'keydown'",[91,1232,1233],{"class":704},", (",[91,1235,1236],{"class":710},"event",[91,1238,869],{"class":704},[91,1240,842],{"class":694},[91,1242,823],{"class":704},[91,1244,1245,1248,1251,1254,1257,1260,1263,1266],{"class":93,"line":147},[91,1246,1247],{"class":694},"  if",[91,1249,1250],{"class":704}," (",[91,1252,1253],{"class":694},"!",[91,1255,1256],{"class":704},"isOpen.value ",[91,1258,1259],{"class":694},"||",[91,1261,1262],{"class":694}," !",[91,1264,1265],{"class":704},"activeGame.value) ",[91,1267,1268],{"class":694},"return\n",[91,1270,1271,1273,1276,1279,1282,1285],{"class":93,"line":623},[91,1272,1247],{"class":694},[91,1274,1275],{"class":704}," (activeGame.value.",[91,1277,1278],{"class":97},"onKey",[91,1280,1281],{"class":704},"(event.key)) event.",[91,1283,1284],{"class":97},"preventDefault",[91,1286,1287],{"class":704},"()\n",[91,1289,1290],{"class":93,"line":735},[91,1291,1292],{"class":704},"})\n",[11,1294,1295,1296,1299,1300,1303,1304,1307],{},"The game returns ",[24,1297,1298],{},"true"," for keys it consumed, so arrows stop scrolling the page but ",[24,1301,1302],{},"F5"," still works, and ",[24,1305,1306],{},"q"," ends the game rather than closing the terminal. That last one took a bug report from myself to get right.",[69,1309,1311],{"id":1310},"a-window-manager-in-a-div","a window manager in a div",[11,1313,1314,1315,1318,1319,1322,1323,1326],{},"Type ",[24,1316,1317],{},"desktop"," and the site boots ",[39,1320,1321],{},"lvOS",": a pretend operating system with draggable windows, a taskbar and a start menu. None of it is real. Every \"window\" is a ",[24,1324,1325],{},"\u003Cdiv>",", and the window manager is about 150 lines.",[11,1328,1329],{},"There is no window object, only a row in an array:",[82,1331,1333],{"className":785,"code":1332,"language":787,"meta":87,"style":87},"interface DesktopWindow {\n  id: string\n  title: string\n  x: number\n  y: number\n  z: number\n  minimized: boolean\n  maximized: boolean\n  \u002F\u002F rect to restore after un-maximizing\u002Fun-snapping\n  restore?: { x: number, y: number, width?: number, height?: number }\n}\n",[24,1334,1335,1345,1355,1364,1374,1383,1392,1402,1411,1417,1467],{"__ignoreMap":87},[91,1336,1337,1340,1343],{"class":93,"line":94},[91,1338,1339],{"class":694},"interface",[91,1341,1342],{"class":97}," DesktopWindow",[91,1344,823],{"class":704},[91,1346,1347,1350,1352],{"class":93,"line":147},[91,1348,1349],{"class":710},"  id",[91,1351,219],{"class":694},[91,1353,1354],{"class":105}," string\n",[91,1356,1357,1360,1362],{"class":93,"line":623},[91,1358,1359],{"class":710},"  title",[91,1361,219],{"class":694},[91,1363,1354],{"class":105},[91,1365,1366,1369,1371],{"class":93,"line":735},[91,1367,1368],{"class":710},"  x",[91,1370,219],{"class":694},[91,1372,1373],{"class":105}," number\n",[91,1375,1376,1379,1381],{"class":93,"line":746},[91,1377,1378],{"class":710},"  y",[91,1380,219],{"class":694},[91,1382,1373],{"class":105},[91,1384,1385,1388,1390],{"class":93,"line":916},[91,1386,1387],{"class":710},"  z",[91,1389,219],{"class":694},[91,1391,1373],{"class":105},[91,1393,1394,1397,1399],{"class":93,"line":1148},[91,1395,1396],{"class":710},"  minimized",[91,1398,219],{"class":694},[91,1400,1401],{"class":105}," boolean\n",[91,1403,1404,1407,1409],{"class":93,"line":1203},[91,1405,1406],{"class":710},"  maximized",[91,1408,219],{"class":694},[91,1410,1401],{"class":105},[91,1412,1414],{"class":93,"line":1413},9,[91,1415,1416],{"class":912},"  \u002F\u002F rect to restore after un-maximizing\u002Fun-snapping\n",[91,1418,1420,1423,1426,1429,1432,1434,1437,1439,1442,1444,1446,1448,1451,1453,1455,1457,1460,1462,1464],{"class":93,"line":1419},10,[91,1421,1422],{"class":710},"  restore",[91,1424,1425],{"class":694},"?:",[91,1427,1428],{"class":704}," { ",[91,1430,1431],{"class":710},"x",[91,1433,219],{"class":694},[91,1435,1436],{"class":105}," number",[91,1438,811],{"class":704},[91,1440,1441],{"class":710},"y",[91,1443,219],{"class":694},[91,1445,1436],{"class":105},[91,1447,811],{"class":704},[91,1449,1450],{"class":710},"width",[91,1452,1425],{"class":694},[91,1454,1436],{"class":105},[91,1456,811],{"class":704},[91,1458,1459],{"class":710},"height",[91,1461,1425],{"class":694},[91,1463,1436],{"class":105},[91,1465,1466],{"class":704}," }\n",[91,1468,1470],{"class":93,"line":1469},11,[91,1471,919],{"class":704},[11,1473,1474,1475,1478],{},"The template ",[24,1476,1477],{},"v-for","s over the array and positions each window with an inline style. Rendering is Vue's problem; the composable only ever mutates numbers.",[11,1480,1481],{},"\"Bring to front\" is the oldest trick in the book — a monotonic counter:",[82,1483,1485],{"className":785,"code":1484,"language":787,"meta":87,"style":87},"let zCounter = 10\nconst focusWindow = (win: DesktopWindow) => {\n  win.z = ++zCounter\n}\n",[24,1486,1487,1500,1524,1537],{"__ignoreMap":87},[91,1488,1489,1492,1495,1497],{"class":93,"line":94},[91,1490,1491],{"class":694},"let",[91,1493,1494],{"class":704}," zCounter ",[91,1496,820],{"class":694},[91,1498,1499],{"class":105}," 10\n",[91,1501,1502,1504,1507,1509,1511,1514,1516,1518,1520,1522],{"class":93,"line":147},[91,1503,794],{"class":694},[91,1505,1506],{"class":97}," focusWindow",[91,1508,1068],{"class":694},[91,1510,1250],{"class":704},[91,1512,1513],{"class":710},"win",[91,1515,219],{"class":694},[91,1517,1342],{"class":97},[91,1519,869],{"class":704},[91,1521,842],{"class":694},[91,1523,823],{"class":704},[91,1525,1526,1529,1531,1534],{"class":93,"line":623},[91,1527,1528],{"class":704},"  win.z ",[91,1530,820],{"class":694},[91,1532,1533],{"class":694}," ++",[91,1535,1536],{"class":704},"zCounter\n",[91,1538,1539],{"class":93,"line":735},[91,1540,919],{"class":704},[11,1542,1543,1544,1547],{},"That single line also powers alt-tab. If focus is just \"highest z\", then switching windows is sorting the open ones by ",[24,1545,1546],{},"z"," and bumping whichever comes next. One idea, two features.",[11,1549,1550,1551,1554,1555,1557,1558,1561,1562,1565,1566,1568,1569,1572,1573,1576],{},"Dragging is one window-level ",[24,1552,1553],{},"pointermove"," doing the math for whichever window is active — no per-window listeners to add and tear down. And snapping needs a ghost: aero-snap's secret is the preview, the translucent rectangle showing where the window will land ",[158,1556,353],{}," you let go. Without it, snapping feels like the app twitching. So ",[24,1559,1560],{},"edgeZone()"," returns ",[24,1563,1564],{},"'left' | 'right' | 'top' | null",", a computed turns that into a rect, and one ghost ",[24,1567,1325],{}," glides to it. On ",[24,1570,1571],{},"pointerup",", the same ",[24,1574,1575],{},"edgeZone"," decides where the window actually goes — the preview never lies, because it runs the exact function the drop does.",[69,1578,1580],{"id":1579},"the-overlay-that-had-to-become-a-route","the overlay that had to become a route",[11,1582,1583,1584,1587,1588,1590,1591,1593,1594,1597,1598,1601],{},"lvOS started as an ",[158,1585,1586],{},"overlay",": a ",[24,1589,925],{}," flag flipped to ",[24,1592,1298],{}," and a ",[24,1595,1596],{},"position: fixed"," div slammed over the page at ",[24,1599,1600],{},"z-index: 95",". It worked, and it had two tells.",[11,1603,1604],{},"The first was a dead scrollbar. The overlay covered the viewport, but the page underneath was still there — hero, footer, all of it, taller than the screen. So the browser still showed a scrollbar. You could scroll a page you couldn't see, behind an OS that didn't move.",[11,1606,1607,1608,1611,1612,1615,1616,1619,1620,1623,1624,1626],{},"The honest fix isn't \"hide the scrollbar\". It's to stop having a tall page behind the desktop at all — which means the desktop shouldn't be an overlay ",[158,1609,1610],{},"on"," a page, it should ",[158,1613,1614],{},"be"," the page. So lvOS moved to a real route, ",[24,1617,1618],{},"\u002Fdesktop",", with a bare layout that renders nothing else and an ",[24,1621,1622],{},"is-lvos"," class on ",[24,1625,759],{}," that locks scrolling for good. Scrollbar gone, because there is genuinely nothing to scroll.",[11,1628,1629,1630,1633,1634,1637,1638,1640],{},"The nicer part is what disappeared. The old model needed a global flag, a plugin to read ",[24,1631,1632],{},"?desktop"," from the query string, a watcher to lazy-mount the component, and ",[24,1635,1636],{},"desktopActive.value = false"," sprinkled through every \"log out\" and \"open this project\" handler. All of it existed to fake navigation. Once ",[24,1639,1618],{}," is a route, navigation is just navigation:",[82,1642,1644],{"className":785,"code":1643,"language":787,"meta":87,"style":87},"const logout = () => router.push('\u002F')\nconst openProject = (slug: string) => router.push(`\u002Fprojects\u002F${slug}`)\n",[24,1645,1646,1674],{"__ignoreMap":87},[91,1647,1648,1650,1653,1655,1658,1660,1663,1666,1668,1671],{"class":93,"line":94},[91,1649,794],{"class":694},[91,1651,1652],{"class":97}," logout",[91,1654,1068],{"class":694},[91,1656,1657],{"class":704}," () ",[91,1659,842],{"class":694},[91,1661,1662],{"class":704}," router.",[91,1664,1665],{"class":97},"push",[91,1667,989],{"class":704},[91,1669,1670],{"class":101},"'\u002F'",[91,1672,1673],{"class":704},")\n",[91,1675,1676,1678,1681,1683,1685,1688,1690,1692,1694,1696,1698,1700,1702,1705,1707,1710],{"class":93,"line":147},[91,1677,794],{"class":694},[91,1679,1680],{"class":97}," openProject",[91,1682,1068],{"class":694},[91,1684,1250],{"class":704},[91,1686,1687],{"class":710},"slug",[91,1689,219],{"class":694},[91,1691,997],{"class":105},[91,1693,869],{"class":704},[91,1695,842],{"class":694},[91,1697,1662],{"class":704},[91,1699,1665],{"class":97},[91,1701,989],{"class":704},[91,1703,1704],{"class":101},"`\u002Fprojects\u002F${",[91,1706,1687],{"class":704},[91,1708,1709],{"class":101},"}`",[91,1711,1673],{"class":704},[11,1713,1714,1715,1718],{},"A whole piece of shared state evaporated, replaced by the router — which was always the right tool for \"show a different screen\". An overlay is a screen you're ",[158,1716,1717],{},"pretending"," is on top; the moment the thing underneath can still scroll, or someone wants to link to it, the seams show.",[69,1720,1722],{"id":1721},"one-game-of-life-four-places","one Game of Life, four places",[11,1724,1725,1726,1730,1731,1735,1736,1739],{},"The centrepiece on the ",[601,1727,1729],{"href":1728},"\u002F","home page"," is Conway's Game of Life — a field of amber cells you can draw into, and click to open ",[601,1732,1734],{"href":1733},"\u002Flife","full-screen",". It also runs as an ASCII toy in the terminal (",[24,1737,1738],{},"life",") and as a window inside lvOS. Four surfaces, one engine.",[11,1741,1742,1743,1746,1747,1750],{},"The trick isn't cleverness. It's ",[158,1744,1745],{},"refusing to mix the rules with the pixels",". The simulation knows nothing about drawing, DOM or Vue — it's a ",[24,1748,1749],{},"Uint8Array"," and one function:",[82,1752,1755],{"className":785,"code":1753,"highlights":1754,"language":787,"meta":87,"style":87},"export function step(grid: Grid, cols: number, rows: number, next = new Uint8Array(grid.length)): Grid {\n  for (let y = 0; y \u003C rows; y++) {\n    for (let x = 0; x \u003C cols; x++) {\n      let n = 0\n      for (let dy = -1; dy \u003C= 1; dy++) {\n        for (let dx = -1; dx \u003C= 1; dx++) {\n          if (!dx && !dy) continue\n          const nx = (x + dx + cols) % cols   \u002F\u002F wrap: a toroidal board\n          const ny = (y + dy + rows) % rows\n          n += grid[ny * cols + nx]!\n        }\n      }\n      const alive = grid[y * cols + x]!\n      next[y * cols + x] = (alive && (n === 2 || n === 3)) || (!alive && n === 3) ? 1 : 0\n    }\n  }\n  return next\n}\n",[1203,1413],[24,1756,1757,1823,1853,1881,1894,1930,1962,1985,2018,2045,2070,2075,2081,2106,2177,2183,2189,2198],{"__ignoreMap":87},[91,1758,1759,1761,1764,1767,1769,1772,1774,1777,1779,1782,1784,1786,1788,1791,1793,1795,1797,1800,1802,1805,1808,1811,1814,1817,1819,1821],{"class":93,"line":94},[91,1760,973],{"class":694},[91,1762,1763],{"class":694}," function",[91,1765,1766],{"class":97}," step",[91,1768,989],{"class":704},[91,1770,1771],{"class":710},"grid",[91,1773,219],{"class":694},[91,1775,1776],{"class":97}," Grid",[91,1778,811],{"class":704},[91,1780,1781],{"class":710},"cols",[91,1783,219],{"class":694},[91,1785,1436],{"class":105},[91,1787,811],{"class":704},[91,1789,1790],{"class":710},"rows",[91,1792,219],{"class":694},[91,1794,1436],{"class":105},[91,1796,811],{"class":704},[91,1798,1799],{"class":710},"next",[91,1801,1068],{"class":694},[91,1803,1804],{"class":694}," new",[91,1806,1807],{"class":97}," Uint8Array",[91,1809,1810],{"class":704},"(grid.",[91,1812,1813],{"class":105},"length",[91,1815,1816],{"class":704},"))",[91,1818,219],{"class":694},[91,1820,1776],{"class":97},[91,1822,823],{"class":704},[91,1824,1825,1828,1830,1832,1835,1837,1839,1842,1844,1847,1850],{"class":93,"line":147},[91,1826,1827],{"class":694},"  for",[91,1829,1250],{"class":704},[91,1831,1491],{"class":694},[91,1833,1834],{"class":704}," y ",[91,1836,820],{"class":694},[91,1838,1128],{"class":105},[91,1840,1841],{"class":704},"; y ",[91,1843,805],{"class":694},[91,1845,1846],{"class":704}," rows; y",[91,1848,1849],{"class":694},"++",[91,1851,1852],{"class":704},") {\n",[91,1854,1855,1858,1860,1862,1865,1867,1869,1872,1874,1877,1879],{"class":93,"line":623},[91,1856,1857],{"class":694},"    for",[91,1859,1250],{"class":704},[91,1861,1491],{"class":694},[91,1863,1864],{"class":704}," x ",[91,1866,820],{"class":694},[91,1868,1128],{"class":105},[91,1870,1871],{"class":704},"; x ",[91,1873,805],{"class":694},[91,1875,1876],{"class":704}," cols; x",[91,1878,1849],{"class":694},[91,1880,1852],{"class":704},[91,1882,1883,1886,1889,1891],{"class":93,"line":735},[91,1884,1885],{"class":694},"      let",[91,1887,1888],{"class":704}," n ",[91,1890,820],{"class":694},[91,1892,1893],{"class":105}," 0\n",[91,1895,1896,1899,1901,1903,1906,1908,1911,1914,1917,1920,1923,1926,1928],{"class":93,"line":746},[91,1897,1898],{"class":694},"      for",[91,1900,1250],{"class":704},[91,1902,1491],{"class":694},[91,1904,1905],{"class":704}," dy ",[91,1907,820],{"class":694},[91,1909,1910],{"class":694}," -",[91,1912,1913],{"class":105},"1",[91,1915,1916],{"class":704},"; dy ",[91,1918,1919],{"class":694},"\u003C=",[91,1921,1922],{"class":105}," 1",[91,1924,1925],{"class":704},"; dy",[91,1927,1849],{"class":694},[91,1929,1852],{"class":704},[91,1931,1932,1935,1937,1939,1942,1944,1946,1948,1951,1953,1955,1958,1960],{"class":93,"line":916},[91,1933,1934],{"class":694},"        for",[91,1936,1250],{"class":704},[91,1938,1491],{"class":694},[91,1940,1941],{"class":704}," dx ",[91,1943,820],{"class":694},[91,1945,1910],{"class":694},[91,1947,1913],{"class":105},[91,1949,1950],{"class":704},"; dx ",[91,1952,1919],{"class":694},[91,1954,1922],{"class":105},[91,1956,1957],{"class":704},"; dx",[91,1959,1849],{"class":694},[91,1961,1852],{"class":704},[91,1963,1964,1967,1969,1971,1974,1977,1979,1982],{"class":93,"line":1148},[91,1965,1966],{"class":694},"          if",[91,1968,1250],{"class":704},[91,1970,1253],{"class":694},[91,1972,1973],{"class":704},"dx ",[91,1975,1976],{"class":694},"&&",[91,1978,1262],{"class":694},[91,1980,1981],{"class":704},"dy) ",[91,1983,1984],{"class":694},"continue\n",[91,1986,1989,1992,1995,1997,2000,2002,2004,2006,2009,2012,2015],{"class":1987,"line":1203},[93,1988],"highlight",[91,1990,1991],{"class":694},"          const",[91,1993,1994],{"class":105}," nx",[91,1996,1068],{"class":694},[91,1998,1999],{"class":704}," (x ",[91,2001,1156],{"class":694},[91,2003,1941],{"class":704},[91,2005,1156],{"class":694},[91,2007,2008],{"class":704}," cols) ",[91,2010,2011],{"class":694},"%",[91,2013,2014],{"class":704}," cols   ",[91,2016,2017],{"class":912},"\u002F\u002F wrap: a toroidal board\n",[91,2019,2021,2023,2026,2028,2031,2033,2035,2037,2040,2042],{"class":2020,"line":1413},[93,1988],[91,2022,1991],{"class":694},[91,2024,2025],{"class":105}," ny",[91,2027,1068],{"class":694},[91,2029,2030],{"class":704}," (y ",[91,2032,1156],{"class":694},[91,2034,1905],{"class":704},[91,2036,1156],{"class":694},[91,2038,2039],{"class":704}," rows) ",[91,2041,2011],{"class":694},[91,2043,2044],{"class":704}," rows\n",[91,2046,2047,2050,2053,2056,2059,2062,2064,2067],{"class":93,"line":1419},[91,2048,2049],{"class":704},"          n ",[91,2051,2052],{"class":694},"+=",[91,2054,2055],{"class":704}," grid[ny ",[91,2057,2058],{"class":694},"*",[91,2060,2061],{"class":704}," cols ",[91,2063,1156],{"class":694},[91,2065,2066],{"class":704}," nx]",[91,2068,2069],{"class":694},"!\n",[91,2071,2072],{"class":93,"line":1469},[91,2073,2074],{"class":704},"        }\n",[91,2076,2078],{"class":93,"line":2077},12,[91,2079,2080],{"class":704},"      }\n",[91,2082,2084,2087,2090,2092,2095,2097,2099,2101,2104],{"class":93,"line":2083},13,[91,2085,2086],{"class":694},"      const",[91,2088,2089],{"class":105}," alive",[91,2091,1068],{"class":694},[91,2093,2094],{"class":704}," grid[y ",[91,2096,2058],{"class":694},[91,2098,2061],{"class":704},[91,2100,1156],{"class":694},[91,2102,2103],{"class":704}," x]",[91,2105,2069],{"class":694},[91,2107,2109,2112,2114,2116,2118,2121,2123,2126,2128,2131,2133,2136,2139,2141,2143,2146,2149,2151,2153,2155,2158,2160,2162,2164,2166,2168,2171,2173,2175],{"class":93,"line":2108},14,[91,2110,2111],{"class":704},"      next[y ",[91,2113,2058],{"class":694},[91,2115,2061],{"class":704},[91,2117,1156],{"class":694},[91,2119,2120],{"class":704}," x] ",[91,2122,820],{"class":694},[91,2124,2125],{"class":704}," (alive ",[91,2127,1976],{"class":694},[91,2129,2130],{"class":704}," (n ",[91,2132,1125],{"class":694},[91,2134,2135],{"class":105}," 2",[91,2137,2138],{"class":694}," ||",[91,2140,1888],{"class":704},[91,2142,1125],{"class":694},[91,2144,2145],{"class":105}," 3",[91,2147,2148],{"class":704},")) ",[91,2150,1259],{"class":694},[91,2152,1250],{"class":704},[91,2154,1253],{"class":694},[91,2156,2157],{"class":704},"alive ",[91,2159,1976],{"class":694},[91,2161,1888],{"class":704},[91,2163,1125],{"class":694},[91,2165,2145],{"class":105},[91,2167,869],{"class":704},[91,2169,2170],{"class":694},"?",[91,2172,1922],{"class":105},[91,2174,1137],{"class":694},[91,2176,1893],{"class":105},[91,2178,2180],{"class":93,"line":2179},15,[91,2181,2182],{"class":704},"    }\n",[91,2184,2186],{"class":93,"line":2185},16,[91,2187,2188],{"class":704},"  }\n",[91,2190,2192,2195],{"class":93,"line":2191},17,[91,2193,2194],{"class":694},"  return",[91,2196,2197],{"class":704}," next\n",[91,2199,2201],{"class":93,"line":2200},18,[91,2202,919],{"class":704},[11,2204,2205,2206,2209],{},"Because it's pure, it's ",[158,2207,2208],{},"testable"," — a blinker oscillates with period two, a block stays put, a lone cell dies. Those are unit tests, not something I eyeball in the browser.",[11,2211,2212,2213,2216,2217,2219,2220,2223,2224,42],{},"The bulky, boring half — sizing the canvas to its container, scaling for device pixel ratio, pausing when the tab is hidden or the visitor prefers reduced motion, cleaning up on unmount — lives once, in a ",[24,2214,2215],{},"useCanvasScene"," composable. The hero and the ",[24,2218,1733],{}," page hand it different ",[24,2221,2222],{},"onFrame"," callbacks; neither reimplements the animation loop. The ASCII version skips the composable entirely, because its canvas is a ",[24,2225,959],{},[11,2227,2228],{},"The payoff: when I wanted Life in a fourth place, it was an afternoon, not a project. No rules were rewritten, so no rules could drift.",[11,2230,2231,2232,2235,2236,2239],{},"That shape kept showing up across the whole rebuild. The ",[158,2233,2234],{},"interesting"," part is usually tiny and pure; the ",[158,2237,2238],{},"bulky"," part is boring and shared. Keep them apart and the same idea renders anywhere you point it.",[69,2241,2243],{"id":2242},"a-time-machine-sort-of","a time machine, sort of",[11,2245,2246,2247,2250,2251,2254],{},"The newest piece is the one I'm most fond of. The site deploys to GitHub Pages, which means every past build still exists in the ",[24,2248,2249],{},"gh-pages"," branch history — and a public repo means a CDN will happily serve any of them. So there's a service worker that intercepts navigations and rewrites them to fetch a ",[158,2252,2253],{},"past"," deploy instead of the current one.",[11,2256,1314,2257,2260,2261,2264,2265,2268],{},[24,2258,2259],{},"git checkout v1.4.0"," in the terminal and the site becomes itself, six months ago, in place. It's reachable four ways off one manifest — the lvOS app, the terminal, the ⌘K palette, and a shareable ",[24,2262,2263],{},"\u002F?v=\u003Cversion>"," link that travels on load — but there's only one ",[24,2266,2267],{},"useTimeMachine"," behind all four.",[11,2270,2271,2272,2275,2276,2279],{},"That's also the ruling principle for the PWA. There is exactly ",[39,2273,2274],{},"one"," service worker: Workbox precaches the build (including the Nuxt Content SQLite wasm, so content queries work offline) and ",[24,2277,2278],{},"importScripts"," the time-machine worker into it. Two registrations fighting over the same scope is a bug that only shows up on someone else's phone, three weeks later.",[69,2281,2283],{"id":2282},"the-easter-eggs","the easter eggs",[11,2285,2286,2287,2290,2291,2294],{},"I won't list them all — run ",[24,2288,2289],{},"secrets",", or visit the ",[24,2292,2293],{},"\u002Fmuseum",", which catalogues every feature as an exhibit. The rule I set myself: every easter egg has to be discoverable from a hint somewhere else. The console hints at the terminal, the terminal hints at the hidden commands, the hidden commands hint at… you get it.",[11,2296,2297],{},"If you find one that isn't hinted at anywhere, that's a bug. Or is it?",[615,2299,2300],{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":87,"searchDepth":147,"depth":147,"links":2302},[2303,2304,2305,2306,2307,2308,2309,2310],{"id":666,"depth":147,"text":667},{"id":774,"depth":147,"text":775},{"id":948,"depth":147,"text":949},{"id":1310,"depth":147,"text":1311},{"id":1579,"depth":147,"text":1580},{"id":1721,"depth":147,"text":1722},{"id":2242,"depth":147,"text":2243},{"id":2282,"depth":147,"text":2283},"2026-07-05","from an unfinished nuxt 2 page to a nuxt 4 site with a terminal, a fake OS, a game of life engine that renders four different ways, and too many easter eggs.",{},"\u002Fblog\u002Frebuilding-this-site","---\ntitle: 'rebuilding my portfolio after 4 years of \"coming soon(ish)\"'\ndate: '2026-07-05'\ndescription: 'from an unfinished nuxt 2 page to a nuxt 4 site with a terminal, a fake OS, a game of life engine that renders four different ways, and too many easter eggs.'\ntags: ['nuxt', 'vue', 'typescript', 'meta']\n---\n\nFor four years, my personal website consisted of a hero, six project cards, and the sentence *\"My personal slice of the web is still under construction and will be live if I can find some time to finish it ;)\"*.\n\nI never found the time. So instead of finishing v1, I deleted it.\n\nWhat replaced it is less a portfolio than a playground: a terminal you can actually use, a pretend operating system with draggable windows, Conway's Game of Life running in four places off one engine, and a service worker that can show you this site as it looked six months ago. This post is the tour, and the handful of ideas that kept all of it from collapsing under its own cuteness.\n\n## the stack\n\nThe rebuild is [Nuxt 4](https:\u002F\u002Fnuxt.com) + TypeScript + [Bulma 1](https:\u002F\u002Fbulma.io), generated as a fully static site. Bulma might seem like an odd choice next to the utility-CSS crowd, but v1 moved everything to CSS variables, which makes theming almost free:\n\n```scss\n@use \"bulma\u002Fsass\" with (\n  $family-primary: '\"Inter\", sans-serif',\n  $family-code: '\"JetBrains Mono\", monospace',\n  $primary: #ffba00\n);\n```\n\nDark mode is just `data-theme=\"dark\"` on the `\u003Chtml>` element — Bulma swaps every variable, and anything custom reads `var(--bulma-*)` so it follows along. Even the canvas animations read their colours from the CSS variables at runtime, which is why the amber cells in the hero know they're in dark mode.\n\nEverything else follows from one constraint I set early: **the site must stay statically generated**. No runtime server, no API I have to keep alive. Anything dynamic is either client-side or an opt-in external service. That constraint turned out to be a feature — it forced most of the \"backend\" ideas below to become honest little client-side systems instead.\n\n## the terminal\n\nThe gimmick I actually wanted to build: press `~` and you get a terminal. It's not an emulator — it's a Vue component and a composable with a command map:\n\n```ts\nconst commands: Record\u003Cstring, TerminalCommand> = {\n  help:     { description: 'List available commands', exec: () => ... },\n  projects: { description: 'List projects', exec: (args) => ... },\n  cd:       { description: 'Go to a page', exec: (args) => navigate(args[0]) },\n  \u002F\u002F ...50 or so more\n}\n```\n\nState lives in `useState`, so the navbar button, the footer link and the overlay all talk to the same terminal instance rather than three lookalikes. Commands can navigate the router, toggle the theme, fetch the GitHub API, or take over the keyboard entirely.\n\nThe rule that keeps it from being a toy: it reads the same data the pages do. `about` prints the same profile the `\u002Fabout` page renders; `blog` lists the same posts the `\u002Fblog` index does. There's no second copy of me to drift out of date.\n\n## a game is a string factory\n\nTaking over the keyboard is how `snake` ended up in there. The fun constraint: the \"screen\" is just a `\u003Cpre>` element. No canvas, no WebGL — monospace text re-rendered eight times a second.\n\nA game is a function handed two callbacks, one to draw a frame and one to report the final output:\n\n```ts\nexport interface GameCallbacks {\n  onFrame(frame: string): void\n  onEnd(lines: string[]): void\n}\n```\n\nEvery tick, snake builds the entire board as a string — box-drawing characters for the walls, `█` for the body, `●` for the food — and hands it over. Because the font is monospace, the \"pixels\" line up:\n\n```ts\nfunction render() {\n  const grid = buildGrid()          \u002F\u002F 26 × 12 characters\n  grid[food.y][food.x] = '●'\n  snake.forEach((cell, i) => {\n    grid[cell.y][cell.x] = i === 0 ? '▓' : '█'\n  })\n  onFrame(header + grid.map((row) => row.join(' ')).join('\\n'))\n}\n```\n\nDiffing a full-frame string sounds wasteful, but the frame is ~700 characters. The DOM update is nothing; your GPU yawns.\n\nStealing the keyboard is the other half. The terminal normally routes keys to an `\u003Cinput>`; while a game is active, the input is hidden and a window-level listener feeds keys to the game:\n\n```ts\nuseEventListener('keydown', (event) => {\n  if (!isOpen.value || !activeGame.value) return\n  if (activeGame.value.onKey(event.key)) event.preventDefault()\n})\n```\n\nThe game returns `true` for keys it consumed, so arrows stop scrolling the page but `F5` still works, and `q` ends the game rather than closing the terminal. That last one took a bug report from myself to get right.\n\n## a window manager in a div\n\nType `desktop` and the site boots **lvOS**: a pretend operating system with draggable windows, a taskbar and a start menu. None of it is real. Every \"window\" is a `\u003Cdiv>`, and the window manager is about 150 lines.\n\nThere is no window object, only a row in an array:\n\n```ts\ninterface DesktopWindow {\n  id: string\n  title: string\n  x: number\n  y: number\n  z: number\n  minimized: boolean\n  maximized: boolean\n  \u002F\u002F rect to restore after un-maximizing\u002Fun-snapping\n  restore?: { x: number, y: number, width?: number, height?: number }\n}\n```\n\nThe template `v-for`s over the array and positions each window with an inline style. Rendering is Vue's problem; the composable only ever mutates numbers.\n\n\"Bring to front\" is the oldest trick in the book — a monotonic counter:\n\n```ts\nlet zCounter = 10\nconst focusWindow = (win: DesktopWindow) => {\n  win.z = ++zCounter\n}\n```\n\nThat single line also powers alt-tab. If focus is just \"highest z\", then switching windows is sorting the open ones by `z` and bumping whichever comes next. One idea, two features.\n\nDragging is one window-level `pointermove` doing the math for whichever window is active — no per-window listeners to add and tear down. And snapping needs a ghost: aero-snap's secret is the preview, the translucent rectangle showing where the window will land *before* you let go. Without it, snapping feels like the app twitching. So `edgeZone()` returns `'left' | 'right' | 'top' | null`, a computed turns that into a rect, and one ghost `\u003Cdiv>` glides to it. On `pointerup`, the same `edgeZone` decides where the window actually goes — the preview never lies, because it runs the exact function the drop does.\n\n## the overlay that had to become a route\n\nlvOS started as an *overlay*: a `useState` flag flipped to `true` and a `position: fixed` div slammed over the page at `z-index: 95`. It worked, and it had two tells.\n\nThe first was a dead scrollbar. The overlay covered the viewport, but the page underneath was still there — hero, footer, all of it, taller than the screen. So the browser still showed a scrollbar. You could scroll a page you couldn't see, behind an OS that didn't move.\n\nThe honest fix isn't \"hide the scrollbar\". It's to stop having a tall page behind the desktop at all — which means the desktop shouldn't be an overlay *on* a page, it should *be* the page. So lvOS moved to a real route, `\u002Fdesktop`, with a bare layout that renders nothing else and an `is-lvos` class on `\u003Chtml>` that locks scrolling for good. Scrollbar gone, because there is genuinely nothing to scroll.\n\nThe nicer part is what disappeared. The old model needed a global flag, a plugin to read `?desktop` from the query string, a watcher to lazy-mount the component, and `desktopActive.value = false` sprinkled through every \"log out\" and \"open this project\" handler. All of it existed to fake navigation. Once `\u002Fdesktop` is a route, navigation is just navigation:\n\n```ts\nconst logout = () => router.push('\u002F')\nconst openProject = (slug: string) => router.push(`\u002Fprojects\u002F${slug}`)\n```\n\nA whole piece of shared state evaporated, replaced by the router — which was always the right tool for \"show a different screen\". An overlay is a screen you're *pretending* is on top; the moment the thing underneath can still scroll, or someone wants to link to it, the seams show.\n\n## one Game of Life, four places\n\nThe centrepiece on the [home page](\u002F) is Conway's Game of Life — a field of amber cells you can draw into, and click to open [full-screen](\u002Flife). It also runs as an ASCII toy in the terminal (`life`) and as a window inside lvOS. Four surfaces, one engine.\n\nThe trick isn't cleverness. It's *refusing to mix the rules with the pixels*. The simulation knows nothing about drawing, DOM or Vue — it's a `Uint8Array` and one function:\n\n```ts {8,9}\nexport function step(grid: Grid, cols: number, rows: number, next = new Uint8Array(grid.length)): Grid {\n  for (let y = 0; y \u003C rows; y++) {\n    for (let x = 0; x \u003C cols; x++) {\n      let n = 0\n      for (let dy = -1; dy \u003C= 1; dy++) {\n        for (let dx = -1; dx \u003C= 1; dx++) {\n          if (!dx && !dy) continue\n          const nx = (x + dx + cols) % cols   \u002F\u002F wrap: a toroidal board\n          const ny = (y + dy + rows) % rows\n          n += grid[ny * cols + nx]!\n        }\n      }\n      const alive = grid[y * cols + x]!\n      next[y * cols + x] = (alive && (n === 2 || n === 3)) || (!alive && n === 3) ? 1 : 0\n    }\n  }\n  return next\n}\n```\n\nBecause it's pure, it's *testable* — a blinker oscillates with period two, a block stays put, a lone cell dies. Those are unit tests, not something I eyeball in the browser.\n\nThe bulky, boring half — sizing the canvas to its container, scaling for device pixel ratio, pausing when the tab is hidden or the visitor prefers reduced motion, cleaning up on unmount — lives once, in a `useCanvasScene` composable. The hero and the `\u002Flife` page hand it different `onFrame` callbacks; neither reimplements the animation loop. The ASCII version skips the composable entirely, because its canvas is a `\u003Cpre>`.\n\nThe payoff: when I wanted Life in a fourth place, it was an afternoon, not a project. No rules were rewritten, so no rules could drift.\n\nThat shape kept showing up across the whole rebuild. The *interesting* part is usually tiny and pure; the *bulky* part is boring and shared. Keep them apart and the same idea renders anywhere you point it.\n\n## a time machine, sort of\n\nThe newest piece is the one I'm most fond of. The site deploys to GitHub Pages, which means every past build still exists in the `gh-pages` branch history — and a public repo means a CDN will happily serve any of them. So there's a service worker that intercepts navigations and rewrites them to fetch a *past* deploy instead of the current one.\n\nType `git checkout v1.4.0` in the terminal and the site becomes itself, six months ago, in place. It's reachable four ways off one manifest — the lvOS app, the terminal, the ⌘K palette, and a shareable `\u002F?v=\u003Cversion>` link that travels on load — but there's only one `useTimeMachine` behind all four.\n\nThat's also the ruling principle for the PWA. There is exactly **one** service worker: Workbox precaches the build (including the Nuxt Content SQLite wasm, so content queries work offline) and `importScripts` the time-machine worker into it. Two registrations fighting over the same scope is a bug that only shows up on someone else's phone, three weeks later.\n\n## the easter eggs\n\nI won't list them all — run `secrets`, or visit the `\u002Fmuseum`, which catalogues every feature as an exhibit. The rule I set myself: every easter egg has to be discoverable from a hint somewhere else. The console hints at the terminal, the terminal hints at the hidden commands, the hidden commands hint at… you get it.\n\nIf you find one that isn't hinted at anywhere, that's a bug. Or is it?\n",{"title":649,"description":2312},"blog\u002Frebuilding-this-site",[2319,2320,2321,2322],"nuxt","vue","typescript","meta","qGHDgP4YJ93BWpaEPRUf7rH-cFx__HFd31xYJXiTUvQ",1786200551259]