@@ cli/Cargo.lock
@@ -3,6 +3,12 @@
version = 3
[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -24,6 +30,12 @@ dependencies = [
]
[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
@@ cli/Cargo.toml
@@ -5,7 +5,7 @@ edition = "2021"
rust-version = "1.75"
description = "Record how your project came together, and prove it."
license = "MIT"
-repository = "https://github.com/pop-cli/pop"
+repository = "https://github.com/vanjamodrinjak/pop"
# This crate is standalone; the repository root is not a cargo workspace.
[workspace]
@@ -34,7 +34,11 @@ serde_json = "1"
sha2 = "0.10"
thiserror = "1"
toml = "0.8"
+ureq = { version = "3", features = ["json"] }
uuid = { version = "1", features = ["v7", "serde"] }
+tera = { version = "1", default-features = false }
+ratatui = "0.29"
+crossterm = "0.28"
[dev-dependencies]
@@ cli/src/cli.rs
@@ -190,7 +190,14 @@ pub struct AskArgs {
/// Parse arguments, without the leading program name.
pub fn parse(args: &[&str]) -> Result<Cli> {
let argv = std::iter::once("pop").chain(args.iter().copied());
- Cli::try_parse_from(argv).map_err(|e| ExitError::new(2, e.render().to_string()).into())
+ Cli::try_parse_from(argv).map_err(|e| {
+ // `--help` and `--version` are not errors: print and exit 0.
+ let code = match e.kind() {
+ clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion => 0,
+ _ => 2,
+ };
+ ExitError::new(code, e.render().to_string()).into()
+ })
}
pub fn dispatch(cli: Cli, ctx: &mut Ctx<'_>) -> Result<()> {
@@ cli/src/commands/summarize.rs
@@ -1,15 +1,183 @@
-//! `pop summarize` and `pop summary edit` — weekly summaries.
+//! `pop summarize` and `pop summary edit` — weekly summaries (spec §4.3).
//!
-//! Task D adds the Ollama client and the heuristic fallback behind these.
+//! Summaries are events like everything else: they are appended, never
+//! rewritten. Regenerating a week adds a new summary event, editing one adds
+//! another, and the page renders the last one per week.
-use anyhow::Result;
+use std::collections::BTreeSet;
+use std::process::Command;
-use crate::{Ctx, ExitError};
+use anyhow::{bail, Context, Result};
-pub fn run(_ctx: &mut Ctx<'_>, _week: Option<u32>) -> Result<()> {
- Err(ExitError::not_implemented("pop summarize"))
+use crate::config::Config;
+use crate::event::{EventKind, Payload, SummaryPayload};
@@ cli/src/gitai.rs
@@ -1,42 +0,0 @@
-//! Reader for Git AI attribution notes.
-//!
-//! Git AI is installed separately by the user and writes its attribution into
-//! a git notes ref. pop never parses editor or tool logs itself; this module
-//! is the only place that knows Git AI's format.
-//!
-//! **Task B implements this.** Until then it returns no sessions, so the
-//! post-commit hook records commits normally on a machine without Git AI.
-//! See `docs/superpowers/specs/gitai-format.md` and the fixtures that will
-//! live in `cli/tests/fixtures/gitai/`.
-
-use anyhow::Result;
-use git2::Repository;
-
-use crate::event::AiSessionPayload;
-
-/// AI sessions attributed to `sha`, newest first.
-///
-/// Returns an empty vector when Git AI is not installed, wrote no note for
@@ cli/src/main.rs
@@ -19,6 +19,11 @@ fn main() {
if let Err(err) = pop::run_in(&cwd, &args) {
if let Some(exit) = err.downcast_ref::<pop::ExitError>() {
// clap already renders its own help and usage text.
+ // Help and version (exit 0) belong on stdout; errors on stderr.
+ if exit.code == 0 {
+ print!("{}", exit.message);
+ std::process::exit(0);
+ }
eprint!(
"{}{}",
exit.message,
@@ cli/src/summarize/heuristic.rs
@@ -0,0 +1,218 @@
+//! The summary pop writes when no model is available.
+//!
+//! Deterministic, plain, and incapable of inventing anything: every sentence
+//! is assembled from counts and from text you wrote yourself. The same week
+//! always produces the same paragraphs, which is what makes it safe to publish.
+
+use super::weeks::{count, Week};
+
+/// Two or three short paragraphs: what happened, what was decided, what stood
+/// out.
+pub fn summarize(week: &Week) -> String {
+ let mut paragraphs = vec![what_happened(week)];
+ let decided = what_was_decided(week);
+ let stood_out = what_stood_out(week);
+ match (decided, stood_out) {
+ (None, None) => paragraphs.push(
+ "No decisions were written down that week, and the analysis found no patterns.".into(),
+ ),
+ (decided, stood_out) => paragraphs.extend(decided.into_iter().chain(stood_out)),
@@ cli/src/summarize/mod.rs
@@ -0,0 +1,349 @@
+//! Weekly summaries (spec §4.3).
+//!
+//! The week is bucketed in [`weeks`], turned into structured text there, and
+//! handed either to a local model ([`ollama`]) or to the template
+//! ([`heuristic`]). Nothing here ever sees a diff or a prompt, and nothing
+//! here ever calls a remote API: `llm.remote` is out of scope in this version.
+
+pub mod heuristic;
+pub mod ollama;
+pub mod weeks;
+
+use std::collections::BTreeMap;
+
+use anyhow::{bail, Result};
+
+use crate::config::Config;
+use crate::event::{Event, EventKind, SummaryPayload};
+
+pub use ollama::{Ollama, DEFAULT_MODEL};
@@ cli/src/summarize/ollama.rs
@@ -0,0 +1,439 @@
+//! A small client for a local Ollama, and nothing else.
+//!
+//! pop talks to `http://localhost:11434` if something is listening there. It
+//! asks for JSON, validates what comes back, and treats anything unexpected as
+//! "no model available" rather than as text worth publishing.
+
+use std::time::Duration;
+
+use anyhow::{anyhow, bail, Context, Result};
+use serde::Deserialize;
+
+/// Where Ollama listens unless you say otherwise.
+pub const DEFAULT_HOST: &str = "http://localhost:11434";
+
+/// The model pop asks for when the config does not name one.
+pub const DEFAULT_MODEL: &str = "qwen3:4b";
+
+/// Is it alive? One second is long enough to answer on localhost.
+const PROBE_TIMEOUT: Duration = Duration::from_secs(1);
@@ cli/src/summarize/weeks.rs
@@ -0,0 +1,597 @@
+//! Weekly buckets: Monday to Sunday, in local time, counted from the first
+//! commit.
+//!
+//! A bucket carries the *structured* facts of a week — commit messages with
+//! file counts, the decisions you wrote down, the patterns analysis found.
+//! Never a diff excerpt, never a prompt: whatever a model sees, it sees from
+//! here, and this is the only place that decides what that is.
+
+use std::collections::BTreeMap;
+use std::fmt::Write as _;
+
+use chrono::{DateTime, Datelike, Duration, Local, NaiveDate};
+
+use crate::event::{CommitPayload, DecisionPayload, Event, EventKind, PatternPayload};
+
+/// At most this many commit lines go into a week's text; the rest are counted.
+const MAX_COMMIT_LINES: usize = 40;
+
+/// How many files a week names as the ones most of the work landed in.
@@ cli/tests/summarize.rs
@@ -0,0 +1,317 @@
+//! `pop summarize` and `pop summary edit`, end to end, with `llm.provider =
+//! none` so the tests never look for a model.
+
+mod common;
+
+use common::*;
+use pop::event::{
+ CommitFile, CommitPayload, DecisionPayload, EventKind, FileStatus, Payload, SummaryPayload,
+};
+use pop::store::Store;
+
+/// Midday UTC on a Wednesday: no timezone on earth moves that out of its week.
+const WEEK_ONE: &str = "2026-01-07T12:00:00.000Z";
+const WEEK_THREE: &str = "2026-01-21T12:00:00.000Z";
+
+fn store(repo: &TestRepo) -> Store {
+ Store::open(&repo.path(".pop/log.db")).unwrap()
+}
+
@@ design/README.md
@@ -0,0 +1,46 @@
+# Design notes: project page and landing
+
+## What is here
+
+- `tokens.css`: the single source of tokens. OKLCH with zero chroma (near black, white, neutral greys), light and dark via `prefers-color-scheme` and `[data-theme]`. Fluid type scale, 4 px spacing scale, small radii, two easing curves. Fonts from Google: Schibsted Grotesk (display), Instrument Sans (text), Geist Mono (terminal, hashes, dates).
+- `project-page.html`: static mock of the page a company reads. One file, CSS inline, no dependencies besides the fonts. Every field of the shared page context is marked with `<!-- ctx: ... -->` for the Tera conversion (task E). `mock/context.json` holds the same sample data in the plan's context shape. The generator that produced both was removed after the last build; edit the HTML directly, it is now the source.
+- The landing lives in `landing/` (Next.js, static export) and imports `tokens.css` through `src/app/globals.css`. `landing/public/example/index.html` is a copy of the mock until task I replaces it with real `pop publish --local` output.
+
+## Decisions
+
+- Zero-chroma palette (user decision). Status is never colour: verified = shield outline plus the word, generated = mono label in a hairline box, remote model = the same label with a double rule, hidden session = dashed ring and italic, decisions = filled diamond, AI sessions = ring, commits = dot.
+- Project page reads top to bottom in the PRD order: intent and delivered, timeline (inline SVG, three lanes on one axis, `<details>` records underneath, click opens the record with or without JS), week summaries, three to five moments, attribution bar, badge. Print stylesheet targets one A4 page with the badge visible; records and summaries are dropped on paper.
+- Landing: one continuous canvas, no horizontal rules between sections. A 1 px spine at the content edge starts inside the hero, continues to the footer and fades out; each section hangs a short tick off it. The hero scene fades into the canvas with a gradient. Copy is one or two sentences per paragraph; the FAQ moved to `/docs/faq/` and the landing links to it once. Content is `hero, example, three blocks with terminal mocks, for whom, privacy, footer`.
+- The hero scene is owned by a separate task (`landing/src/components/hero/`). The landing mounts `<Hero />` as a full-bleed layer and keeps the headline, subheadline, install block and example link readable on top with a radial fade.
+- Install block: system-wide command by default, one quiet line for `--project` that reveals the per-project command. The copy button fires the Plausible `copy-install` tagged event (props `os`, `mode`).
+- Motion: only where it carries meaning. Timeline draws its axis and points arrive in order (CSS, 1.2 s, off under reduced motion). Copy button crossfades its label through a 3 px blur, 160 ms, `scale(0.97)` on press. Nothing else moves outside the hero.
+
+## Skill passes
+
@@ design/brand/logo/explore/concept-1-rings-row.svg
@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
+<defs><clipPath id="mac"><circle cx="187.0" cy="195.1" r="54.4"/></clipPath><mask id="ma1" maskUnits="userSpaceOnUse" x="0" y="0" width="512" height="512"><rect width="512" height="512" fill="#fff"/><path clip-path="url(#ma1c)" d="M26 256A92 92 0 1 0 210 256A92 92 0 1 0 26 256Z" stroke="#000" stroke-width="78"/></mask><clipPath id="mbc"><circle cx="187.0" cy="316.9" r="54.4"/></clipPath><mask id="mb1" maskUnits="userSpaceOnUse" x="0" y="0" width="512" height="512"><rect width="512" height="512" fill="#fff"/><path clip-path="url(#mb1c)" d="M164 256A92 92 0 1 0 348 256A92 92 0 1 0 164 256Z" stroke="#000" stroke-width="78"/></mask></defs>
+<path d="M26 256A92 92 0 1 0 210 256A92 92 0 1 0 26 256Z" stroke-width="34" mask="url(#mb1)"/>
+<path d="M164 256A92 92 0 1 0 348 256A92 92 0 1 0 164 256Z" stroke-width="34" mask="url(#ma1)"/>
+<defs><clipPath id="mac"><circle cx="325.0" cy="316.9" r="54.4"/></clipPath><mask id="ma2" maskUnits="userSpaceOnUse" x="0" y="0" width="512" height="512"><rect width="512" height="512" fill="#fff"/><path clip-path="url(#ma2c)" d="M164 256A92 92 0 1 0 348 256A92 92 0 1 0 164 256Z" stroke="#000" stroke-width="78"/></mask><clipPath id="mbc"><circle cx="325.0" cy="195.1" r="54.4"/></clipPath><mask id="mb2" maskUnits="userSpaceOnUse" x="0" y="0" width="512" height="512"><rect width="512" height="512" fill="#fff"/><path clip-path="url(#mb2c)" d="M302 256A92 92 0 1 0 486 256A92 92 0 1 0 302 256Z" stroke="#000" stroke-width="78"/></mask></defs>
+<path d="M302 256A92 92 0 1 0 486 256A92 92 0 1 0 302 256Z" stroke-width="34" mask="url(#ma2)"/>
+</svg>
@@ design/brand/logo/explore/concept-2-arc-seal.svg
@@ -0,0 +1,8 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
+<path d="M282.3 90.1A168 168 0 0 1 412.8 316.2" stroke-width="40"/>
+<path d="M386.6 361.7A168 168 0 0 1 125.4 361.7" stroke-width="40"/>
+<path d="M99.2 316.2A168 168 0 0 1 229.7 90.1" stroke-width="40"/>
+<circle cx="256.0" cy="88.0" r="22.0" fill="currentColor" stroke="none"/>
+<circle cx="401.5" cy="340.0" r="22.0" fill="currentColor" stroke="none"/>
+<circle cx="110.5" cy="340.0" r="22.0" fill="currentColor" stroke="none"/>
+</svg>
@@ design/brand/logo/explore/concept-3-square-links.svg
@@ -0,0 +1,5 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
+<defs><clipPath id="mac"><circle cx="306.0" cy="206.0" r="64.0"/></clipPath><mask id="ma" maskUnits="userSpaceOnUse" x="0" y="0" width="512" height="512"><rect width="512" height="512" fill="#fff"/><path clip-path="url(#mac)" d="M126 78H258A48 48 0 0 1 306 126V258A48 48 0 0 1 258 306H126A48 48 0 0 1 78 258V126A48 48 0 0 1 126 78Z" stroke="#000" stroke-width="84"/></mask><clipPath id="mbc"><circle cx="206.0" cy="306.0" r="64.0"/></clipPath><mask id="mb" maskUnits="userSpaceOnUse" x="0" y="0" width="512" height="512"><rect width="512" height="512" fill="#fff"/><path clip-path="url(#mbc)" d="M254 206H386A48 48 0 0 1 434 254V386A48 48 0 0 1 386 434H254A48 48 0 0 1 206 386V254A48 48 0 0 1 254 206Z" stroke="#000" stroke-width="84"/></mask></defs>
+<path d="M126 78H258A48 48 0 0 1 306 126V258A48 48 0 0 1 258 306H126A48 48 0 0 1 78 258V126A48 48 0 0 1 126 78Z" stroke-width="40" mask="url(#mb)"/>
+<path d="M254 206H386A48 48 0 0 1 434 254V386A48 48 0 0 1 386 434H254A48 48 0 0 1 206 386V254A48 48 0 0 1 254 206Z" stroke-width="40" mask="url(#ma)"/>
+</svg>
@@ design/brand/logo/explore/concept-4-seal-record.svg
@@ -0,0 +1,5 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
+<defs><clipPath id="mac"><circle cx="266.0" cy="136.0" r="64.0"/></clipPath><mask id="ma" maskUnits="userSpaceOnUse" x="0" y="0" width="512" height="512"><rect width="512" height="512" fill="#fff"/><path clip-path="url(#mac)" d="M26 256A150 150 0 1 0 326 256A150 150 0 1 0 26 256Z" stroke="#000" stroke-width="84"/></mask><clipPath id="mbc"><circle cx="266.0" cy="376.0" r="64.0"/></clipPath><mask id="mb" maskUnits="userSpaceOnUse" x="0" y="0" width="512" height="512"><rect width="512" height="512" fill="#fff"/><path clip-path="url(#mbc)" d="M204 136H356A44 44 0 0 1 400 180V332A44 44 0 0 1 356 376H204A44 44 0 0 1 160 332V180A44 44 0 0 1 204 136Z" stroke="#000" stroke-width="84"/></mask></defs>
+<path d="M26 256A150 150 0 1 0 326 256A150 150 0 1 0 26 256Z" stroke-width="40" mask="url(#mb)"/>
+<path d="M204 136H356A44 44 0 0 1 400 180V332A44 44 0 0 1 356 376H204A44 44 0 0 1 160 332V180A44 44 0 0 1 204 136Z" stroke-width="40" mask="url(#ma)"/>
+</svg>
@@ design/brand/logo/explore/concept-5-ledger.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
+<path d="M256 84V428" stroke-width="36"/>
+<path d="M170 96H342A24 24 0 0 1 366 120V152A24 24 0 0 1 342 176H170A24 24 0 0 1 146 152V120A24 24 0 0 1 170 96Z" stroke-width="36"/>
+<path d="M170 216H342A24 24 0 0 1 366 240V272A24 24 0 0 1 342 296H170A24 24 0 0 1 146 272V240A24 24 0 0 1 170 216Z" stroke-width="36"/>
+<path d="M170 336H342A24 24 0 0 1 366 360V392A24 24 0 0 1 342 416H170A24 24 0 0 1 146 392V360A24 24 0 0 1 170 336Z" stroke-width="36" fill="currentColor"/>
+</svg>
@@ design/brand/logo/explore/concept-6-timeline.svg
@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
+<path d="M92 256H340" stroke-width="36"/>
+<circle cx="112" cy="256" r="38" fill="currentColor" stroke="none"/>
+<circle cx="256" cy="256" r="38" fill="currentColor" stroke="none"/>
+<circle cx="400" cy="256" r="60" stroke-width="36" fill="none"/>
+<circle cx="400" cy="256" r="22" fill="currentColor" stroke="none"/>
+</svg>
@@ design/brand/logo/explore/concept-7-p-link.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
+<path d="M132 176V452" stroke-width="44"/>
+<defs><clipPath id="mac"><circle cx="286.0" cy="183.6" r="70.4"/></clipPath><mask id="ma" maskUnits="userSpaceOnUse" x="0" y="0" width="512" height="512"><rect width="512" height="512" fill="#fff"/><path clip-path="url(#mac)" d="M132 256A94 94 0 1 0 320 256A94 94 0 1 0 132 256Z" stroke="#000" stroke-width="88"/></mask><clipPath id="mbc"><circle cx="286.0" cy="328.4" r="70.4"/></clipPath><mask id="mb" maskUnits="userSpaceOnUse" x="0" y="0" width="512" height="512"><rect width="512" height="512" fill="#fff"/><path clip-path="url(#mbc)" d="M252 256A94 94 0 1 0 440 256A94 94 0 1 0 252 256Z" stroke="#000" stroke-width="88"/></mask></defs>
+<path d="M132 256A94 94 0 1 0 320 256A94 94 0 1 0 132 256Z" stroke-width="44" mask="url(#mb)"/>
+<path d="M252 256A94 94 0 1 0 440 256A94 94 0 1 0 252 256Z" stroke-width="44" mask="url(#ma)"/>
+</svg>
@@ design/brand/logo/explore/concept-8-segments.svg
@@ -0,0 +1,9 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
+<path d="M82 196H130A26 26 0 0 1 156 222V290A26 26 0 0 1 130 316H82A26 26 0 0 1 56 290V222A26 26 0 0 1 82 196Z" stroke-width="30"/>
+<path d="M202 196H250A26 26 0 0 1 276 222V290A26 26 0 0 1 250 316H202A26 26 0 0 1 176 290V222A26 26 0 0 1 202 196Z" stroke-width="30"/>
+<path d="M322 196H370A26 26 0 0 1 396 222V290A26 26 0 0 1 370 316H322A26 26 0 0 1 296 290V222A26 26 0 0 1 322 196Z" stroke-width="30"/>
+<path d="M430 196H442A14 14 0 0 1 456 210V302A14 14 0 0 1 442 316H430A14 14 0 0 1 416 302V210A14 14 0 0 1 430 196Z" stroke-width="30" fill="currentColor"/>
+<path d="M156 256H176" stroke-width="30"/>
+<path d="M276 256H296" stroke-width="30"/>
+<path d="M396 256H416" stroke-width="30"/>
+</svg>
@@ design/brand/logo/explore/refine/r3-a-primary.svg
@@ -0,0 +1,5 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
+<defs><clipPath id="mac"><circle cx="190.0" cy="366.0" r="70.4"/></clipPath><mask id="ma" maskUnits="userSpaceOnUse" x="0" y="0" width="512" height="512"><rect width="512" height="512" fill="#fff"/><path clip-path="url(#mac)" d="M110.0 146.0H250.0A40 40 0 0 1 290.0 186.0V326.0A40 40 0 0 1 250.0 366.0H110.0A40 40 0 0 1 70.0 326.0V186.0A40 40 0 0 1 110.0 146.0Z" stroke="#000" stroke-width="88"/></mask><clipPath id="mbc"><circle cx="190.0" cy="146.0" r="70.4"/></clipPath><mask id="mb" maskUnits="userSpaceOnUse" x="0" y="0" width="512" height="512"><rect width="512" height="512" fill="#fff"/><path clip-path="url(#mbc)" d="M142.0 256A150 150 0 1 0 442.0 256A150 150 0 1 0 142.0 256Z" stroke="#000" stroke-width="88"/></mask></defs>
+<path d="M110.0 146.0H250.0A40 40 0 0 1 290.0 186.0V326.0A40 40 0 0 1 250.0 366.0H110.0A40 40 0 0 1 70.0 326.0V186.0A40 40 0 0 1 110.0 146.0Z" stroke-width="44" mask="url(#mb)"/>
+<path d="M142.0 256A150 150 0 1 0 442.0 256A150 150 0 1 0 142.0 256Z" stroke-width="44" mask="url(#ma)"/>
+</svg>
@@ design/brand/logo/explore/refine/r3-b-primary-g30.svg
@@ -0,0 +1,5 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
+<defs><clipPath id="mac"><circle cx="190.0" cy="366.0" r="70.4"/></clipPath><mask id="ma" maskUnits="userSpaceOnUse" x="0" y="0" width="512" height="512"><rect width="512" height="512" fill="#fff"/><path clip-path="url(#mac)" d="M110.0 146.0H250.0A40 40 0 0 1 290.0 186.0V326.0A40 40 0 0 1 250.0 366.0H110.0A40 40 0 0 1 70.0 326.0V186.0A40 40 0 0 1 110.0 146.0Z" stroke="#000" stroke-width="88"/></mask><clipPath id="mbc"><circle cx="190.0" cy="146.0" r="70.4"/></clipPath><mask id="mb" maskUnits="userSpaceOnUse" x="0" y="0" width="512" height="512"><rect width="512" height="512" fill="#fff"/><path clip-path="url(#mbc)" d="M142.0 256A150 150 0 1 0 442.0 256A150 150 0 1 0 142.0 256Z" stroke="#000" stroke-width="88"/></mask></defs>
+<path d="M110.0 146.0H250.0A40 40 0 0 1 290.0 186.0V326.0A40 40 0 0 1 250.0 366.0H110.0A40 40 0 0 1 70.0 326.0V186.0A40 40 0 0 1 110.0 146.0Z" stroke-width="44" mask="url(#mb)"/>
+<path d="M142.0 256A150 150 0 1 0 442.0 256A150 150 0 1 0 142.0 256Z" stroke-width="44" mask="url(#ma)"/>
+</svg>
@@ design/brand/logo/explore/refine/r3-c-compact-64.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
+<path d="M114.0 146.0H246.0A44 44 0 0 1 290.0 190.0V322.0A44 44 0 0 1 246.0 366.0H114.0A44 44 0 0 1 70.0 322.0V190.0A44 44 0 0 1 114.0 146.0Z" stroke-width="64"/>
+<path d="M142.0 256A150 150 0 1 0 442.0 256A150 150 0 1 0 142.0 256Z" stroke-width="64"/>
+</svg>
@@ design/brand/logo/explore/refine/r3-d-compact-76.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
+<path d="M118.0 146.0H242.0A48 48 0 0 1 290.0 194.0V318.0A48 48 0 0 1 242.0 366.0H118.0A48 48 0 0 1 70.0 318.0V194.0A48 48 0 0 1 118.0 146.0Z" stroke-width="76"/>
+<path d="M142.0 256A150 150 0 1 0 442.0 256A150 150 0 1 0 142.0 256Z" stroke-width="76"/>
+</svg>
@@ design/brand/logo/explore/refine/r3-e-compact-cut64.svg
@@ -0,0 +1,5 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
+<defs><clipPath id="mac"><circle cx="190.0" cy="366.0" r="102.4"/></clipPath><mask id="ma" maskUnits="userSpaceOnUse" x="0" y="0" width="512" height="512"><rect width="512" height="512" fill="#fff"/><path clip-path="url(#mac)" d="M114.0 146.0H246.0A44 44 0 0 1 290.0 190.0V322.0A44 44 0 0 1 246.0 366.0H114.0A44 44 0 0 1 70.0 322.0V190.0A44 44 0 0 1 114.0 146.0Z" stroke="#000" stroke-width="108"/></mask><clipPath id="mbc"><circle cx="190.0" cy="146.0" r="102.4"/></clipPath><mask id="mb" maskUnits="userSpaceOnUse" x="0" y="0" width="512" height="512"><rect width="512" height="512" fill="#fff"/><path clip-path="url(#mbc)" d="M142.0 256A150 150 0 1 0 442.0 256A150 150 0 1 0 142.0 256Z" stroke="#000" stroke-width="108"/></mask></defs>
+<path d="M114.0 146.0H246.0A44 44 0 0 1 290.0 190.0V322.0A44 44 0 0 1 246.0 366.0H114.0A44 44 0 0 1 70.0 322.0V190.0A44 44 0 0 1 114.0 146.0Z" stroke-width="64" mask="url(#mb)"/>
+<path d="M142.0 256A150 150 0 1 0 442.0 256A150 150 0 1 0 142.0 256Z" stroke-width="64" mask="url(#ma)"/>
+</svg>
@@ design/mock/build.mjs
@@ -1,564 +0,0 @@
-// Builds design/project-page.html (static mock) and design/mock/context.json
-// (the same data in the shared page-context shape from the plan).
-// Run: node design/mock/build.mjs
-import { readFileSync, writeFileSync } from "node:fs";
-import { dirname, join } from "node:path";
-import { fileURLToPath } from "node:url";
-import { project, rows, patterns, moments, ratio, summaries, badge } from "./data.mjs";
-
-const here = dirname(fileURLToPath(import.meta.url));
-const tokens = readFileSync(join(here, "..", "tokens.css"), "utf8");
-
-// ---------- context -------------------------------------------------------
-
-const T0 = Date.parse(project.first_ts);
-const T1 = Date.parse(project.last_ts);
-const xOf = (ts) => (Date.parse(ts) - T0) / (T1 - T0);
-const uuid = (i) => `0199${(0x4c00 + i).toString(16)}-7a2e-7${String(i).padStart(3, "0")}-8b1c-${(0x9e0000000000 + i * 7919).toString(16).padStart(12, "0")}`;
-
-const kindOf = { c: "commit", d: "decision", a: "ai_session", h: "hidden" };
@@ design/mock/context.json
@@ -1001,7 +1001,7 @@
"id": "01994c33-7a2e-7051-8b1c-9e000006299d",
"ts": "2026-09-15T14:30:00Z",
"kind": "decision",
- "title": "Timeline is inline SVG with a <details> fallback. No JS framework. The page must open from a USB stick in 2030.",
+ "title": "Timeline is inline SVG with a details fallback. No JS framework: the page must still open, without a build step, years from now.",
"detail": null,
"sha": null,
"x": 0.8605
@@ -1432,7 +1432,7 @@
{
"title": "The preview hid the wrong row",
"kind": "fix_after_ai",
- "body": "Claude wrote the ratatui list with a cursor index relative to the visible window. Pressing h after scrolling hid a different session than the one under the cursor. Fixed four hours later by adding the scroll offset. Small bug, but it is exactly the kind a hiring manager wants to see you catch.",
+ "body": "Claude wrote the ratatui list with a cursor index relative to the visible window. Pressing h after scrolling hid a different session than the one under the cursor. Fixed four hours later by adding the scroll offset. A small bug, caught the same afternoon.",
"refs": [
"01994c37-7a2e-7055-8b1c-9e000006a559",
"01994c38-7a2e-7056-8b1c-9e000006c448",
@@ design/mock/data.mjs
@@ -1,376 +0,0 @@
-// Sample data for the project-page mock: pop's own first two weeks.
-// Compact rows are expanded into the shared page context (see plan §"Shared page context").
-
-export const project = {
- name: "pop",
- what: "A Rust CLI that records how a project came together and turns it into one page a hiring manager reads in two minutes.",
- for_whom: "Students and juniors applying for a first job, who work with Claude Code and have nothing but a finished repo to show.",
- tools: ["claude-code"],
- delivered_url: "https://github.com/vanjamodrinjak/pop",
- screenshot: null,
- first_ts: "2026-09-03T09:12:00Z",
- last_ts: "2026-09-17T14:02:00Z",
-};
-
-// [ts, kind, title, detail, extra]
-// kind: c = commit, d = decision, a = ai_session, h = hidden ai_session
-// extra for commits: { sha, files: [[path, +, -]], excerpt }
-// extra for ai sessions: { model, files: [[path, ai_lines, modified]], accepted }
-export const rows = [
@@ design/project-page.html
@@ -14,7 +14,8 @@
/* pop design tokens
Consumed by: design/project-page.html (inlined), landing/src/site.css (Tailwind v4 @theme inline).
Fonts (Google Fonts): Schibsted Grotesk (display), Instrument Sans (text), Geist Mono (terminal, hashes).
- Colors are OKLCH. One accent hue (ochre-rust, hue 55). Neutrals are tinted toward the same hue. */
+ Colors are OKLCH with zero chroma: near black, white and neutral greys. No accent hue.
+ Status (verified, generated, hidden) is carried by weight, outline and type, never by colour. */
:root {
color-scheme: light dark;
@@ -36,7 +37,7 @@
--leading-tight: 1.05;
--leading-snug: 1.25;
--leading-body: 1.55;
- --tracking-display: -0.025em;
+ --tracking-display: -0.03em;
--tracking-tight: -0.012em;
--measure: 62ch;
@@ -70,86 +71,77 @@
@@ design/tokens.css
@@ -1,7 +1,8 @@
/* pop design tokens
Consumed by: design/project-page.html (inlined), landing/src/site.css (Tailwind v4 @theme inline).
Fonts (Google Fonts): Schibsted Grotesk (display), Instrument Sans (text), Geist Mono (terminal, hashes).
- Colors are OKLCH. One accent hue (ochre-rust, hue 55). Neutrals are tinted toward the same hue. */
+ Colors are OKLCH with zero chroma: near black, white and neutral greys. No accent hue.
+ Status (verified, generated, hidden) is carried by weight, outline and type, never by colour. */
:root {
color-scheme: light dark;
@@ -23,7 +24,7 @@
--leading-tight: 1.05;
--leading-snug: 1.25;
--leading-body: 1.55;
- --tracking-display: -0.025em;
+ --tracking-display: -0.03em;
--tracking-tight: -0.012em;
--measure: 62ch;
@@ -57,86 +58,77 @@
@@ landing/.gitignore
@@ -39,3 +39,6 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
+
+# static export
+/out
@@ landing/.prettierignore
@@ -0,0 +1,6 @@
+.next
+out
+node_modules
+public/example
+package-lock.json
+pnpm-lock.yaml
@@ landing/.prettierrc
@@ -0,0 +1,6 @@
+{
+ "printWidth": 100,
+ "singleQuote": false,
+ "trailingComma": "all",
+ "semi": true
+}
@@ landing/AGENTS.md
@@ -0,0 +1,9 @@
+<!-- BEGIN:nextjs-agent-rules -->
+
+# This is NOT the Next.js you know
+
+This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
+
+This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
+
+<!-- END:nextjs-agent-rules -->
@@ landing/CLAUDE.md
@@ -0,0 +1 @@
+@AGENTS.md
@@ landing/eslint.config.mjs
@@ -1,18 +1,13 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
+import prettier from "eslint-config-prettier/flat";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
- // Override default ignores of eslint-config-next.
- globalIgnores([
- // Default ignores of eslint-config-next:
- ".next/**",
- "out/**",
- "build/**",
- "next-env.d.ts",
- ]),
+ prettier,
+ globalIgnores([".next/**", "out/**", "build/**", "next-env.d.ts", "public/**"]),
]);
@@ landing/next.config.ts
@@ -1,7 +1,12 @@
+import path from "node:path";
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
- /* config options here */
+ output: "export",
+ trailingSlash: true,
+ images: { unoptimized: true },
+ // design/tokens.css lives one level up; Turbopack must see the monorepo root.
+ turbopack: { root: path.join(__dirname, "..") },
};
export default nextConfig;
@@ landing/package.json
@@ -5,13 +5,20 @@
"scripts": {
"dev": "next dev",
"build": "next build",
- "start": "next start",
- "lint": "eslint"
+ "start": "pnpm dlx serve out",
+ "lint": "eslint",
+ "typecheck": "tsc --noEmit",
+ "format": "prettier --write .",
+ "format:check": "prettier --check ."
},
"dependencies": {
+ "@gsap/react": "^2.1.2",
+ "@types/three": "^0.186.0",
+ "gsap": "^3.15.0",
"next": "16.3.5",
"react": "19.2.8",
- "react-dom": "19.2.8"
+ "react-dom": "19.2.8",
@@ landing/pnpm-lock.yaml
@@ -0,0 +1,4281 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ '@gsap/react':
+ specifier: ^2.1.2
+ version: 2.1.2(gsap@3.15.0)(react@19.2.8)
+ '@types/three':
+ specifier: ^0.186.0
+ version: 0.186.0
+ gsap:
+ specifier: ^3.15.0
+ version: 3.15.0
@@ landing/public/example/index.html
@@ -0,0 +1,827 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<!-- ctx: project.name -->
+<title>pop · how it came together</title>
+<meta name="description" content="A Rust CLI that records how a project came together and turns it into one page a hiring manager reads in two minutes.">
+<meta name="robots" content="noindex">
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Schibsted+Grotesk:wght@500;600;700&family=Instrument+Sans:wght@400;500;600&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
+<style>
+/* pop design tokens
+ Consumed by: design/project-page.html (inlined), landing/src/site.css (Tailwind v4 @theme inline).
+ Fonts (Google Fonts): Schibsted Grotesk (display), Instrument Sans (text), Geist Mono (terminal, hashes).
+ Colors are OKLCH with zero chroma: near black, white and neutral greys. No accent hue.
+ Status (verified, generated, hidden) is carried by weight, outline and type, never by colour. */
+
@@ landing/public/file.svg
@@ -1 +0,0 @@
-<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
\ No newline at end of file
@@ landing/public/globe.svg
@@ -1 +0,0 @@
-<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
\ No newline at end of file
@@ landing/public/next.svg
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
\ No newline at end of file
@@ landing/public/vercel.svg
@@ -1 +0,0 @@
-<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
\ No newline at end of file
@@ landing/public/window.svg
@@ -1 +0,0 @@
-<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
\ No newline at end of file
@@ landing/src/app/(en)/layout.tsx
@@ -0,0 +1,14 @@
+import type { Metadata } from "next";
+import type { ReactNode } from "react";
+import { Shell } from "@/components/Shell";
+import { en } from "@/i18n/en";
+
+export const metadata: Metadata = {
+ title: en.title,
+ description: en.description,
+ alternates: { languages: { en: "/", hr: "/hr/", de: "/de/" } },
+};
+
+export default function EnLayout({ children }: { children: ReactNode }) {
+ return <Shell lang="en">{children}</Shell>;
+}
@@ landing/src/app/(en)/page.tsx
@@ -0,0 +1,6 @@
+import { LandingPage } from "@/components/LandingPage";
+import { en } from "@/i18n/en";
+
+export default function Page() {
+ return <LandingPage dict={en} />;
+}
@@ landing/src/app/[lang]/layout.tsx
@@ -0,0 +1,30 @@
+import type { Metadata } from "next";
+import type { ReactNode } from "react";
+import { notFound } from "next/navigation";
+import { dicts, isLang } from "@/i18n";
+import { Shell } from "@/components/Shell";
+
+export const dynamicParams = false;
+
+export function generateStaticParams() {
+ return [{ lang: "hr" }, { lang: "de" }];
+}
+
+type Params = { params: Promise<{ lang: string }> };
+
+export async function generateMetadata({ params }: Params): Promise<Metadata> {
+ const { lang } = await params;
+ if (!isLang(lang)) return {};
+ const d = dicts[lang];
+ return {
@@ landing/src/app/[lang]/page.tsx
@@ -0,0 +1,9 @@
+import { notFound } from "next/navigation";
+import { LandingPage } from "@/components/LandingPage";
+import { dicts, isLang } from "@/i18n";
+
+export default async function Page({ params }: { params: Promise<{ lang: string }> }) {
+ const { lang } = await params;
+ if (!isLang(lang) || lang === "en") notFound();
+ return <LandingPage dict={dicts[lang]} />;
+}
@@ landing/src/app/fonts.ts
@@ -0,0 +1,24 @@
+import { Geist_Mono, Instrument_Sans, Schibsted_Grotesk } from "next/font/google";
+
+export const display = Schibsted_Grotesk({
+ subsets: ["latin", "latin-ext"],
+ weight: ["500", "600", "700"],
+ variable: "--font-display",
+ display: "swap",
+});
+
+export const text = Instrument_Sans({
+ subsets: ["latin", "latin-ext"],
+ weight: ["400", "500", "600"],
+ variable: "--font-text",
+ display: "swap",
+});
+
+export const mono = Geist_Mono({
+ subsets: ["latin"],
+ weight: ["400", "500"],
@@ landing/src/app/globals.css
@@ -1,26 +1,565 @@
+/* pop landing. Tokens come from design/tokens.css; Tailwind v4 maps them below. */
@import "tailwindcss";
+@import "../../../design/tokens.css";
-:root {
- --background: #ffffff;
- --foreground: #171717;
+@theme inline {
+ --font-sans: var(--font-text);
+ --font-head: var(--font-display);
+ --font-code: var(--font-mono);
+
+ --color-bg: var(--bg);
+ --color-bg-sunk: var(--bg-sunk);
+ --color-surface: var(--surface);
+ --color-line: var(--line);
+ --color-line-strong: var(--line-strong);
+ --color-ink: var(--ink);
+ --color-ink-2: var(--ink-2);
@@ landing/src/app/layout.tsx
@@ -1,29 +0,0 @@
-import type { Metadata } from "next";
-import { Geist, Geist_Mono } from "next/font/google";
-import "./globals.css";
-
-const geistSans = Geist({
- variable: "--font-geist-sans",
- subsets: ["latin"],
-});
-
-const geistMono = Geist_Mono({
- variable: "--font-geist-mono",
- subsets: ["latin"],
-});
-
-export const metadata: Metadata = {
- title: "Create Next App",
- description: "Generated by create next app",
-};
-
@@ landing/src/app/page.tsx
@@ -1,69 +0,0 @@
-import Image from "next/image";
-
-export default function Home() {
- return (
- <div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
- <main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
- <Image
- className="dark:invert h-5 w-[100px]"
- src="/next.svg"
- alt="Next.js logo"
- width={100}
- height={20}
- priority
- />
- <div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
- <h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
- To get started, edit the{" "}
- <code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]">
- page.tsx
@@ landing/src/components/InstallBlock.tsx
@@ -0,0 +1,140 @@
+"use client";
+
+import { useState } from "react";
+import type { Dict } from "@/i18n";
+
+type Os = "mac" | "win";
+
+const CMD: Record<Os, { system: string; project: string }> = {
+ mac: {
+ system: "curl -fsSL https://pop.dev/install.sh | sh",
+ project: "curl -fsSL https://pop.dev/install.sh | sh -s -- --project",
+ },
+ win: {
+ system: "irm https://pop.dev/install.ps1 | iex",
+ project: "& ([scriptblock]::Create((irm https://pop.dev/install.ps1))) -Project",
+ },
+};
+
+const PROMPT: Record<Os, string> = { mac: "$", win: ">" };
@@ landing/src/components/LandingPage.tsx
@@ -0,0 +1,173 @@
+import type { Dict } from "@/i18n";
+import Hero from "./hero/Hero";
+import { InstallBlock } from "./InstallBlock";
+import { LangSwitch } from "./LangSwitch";
+import { Terminal } from "./Terminal";
+import { termInstall, termNote, termPublish } from "./terminals";
+
+const GITHUB = "https://github.com/vanjamodrinjak/pop";
+const DOCS = "/docs/";
+
+function Header({ dict }: { dict: Dict }) {
+ return (
+ <header className="wrap flex items-center justify-between gap-6 py-5 text-sm">
+ <a href="#top" className="font-head text-base font-bold text-ink no-underline">
+ pop
+ </a>
+ <nav className="hidden items-center gap-6 sm:flex" aria-label="Site">
+ <a className="nav-link" href="#example">
+ {dict.nav.example}
@@ landing/src/components/LangSwitch.tsx
@@ -0,0 +1,34 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { LANGS, LANG_PATH, type Lang } from "@/i18n";
+
+const NAMES: Record<Lang, string> = { en: "EN", hr: "HR", de: "DE" };
+
+// Lists all three languages everywhere; each link carries the current section hash
+// so the reader lands on the same place in the other language.
+export function LangSwitch({ current }: { current: Lang }) {
+ const [hash, setHash] = useState("");
+ useEffect(() => {
+ const read = () => setHash(window.location.hash);
+ read();
+ window.addEventListener("hashchange", read);
+ return () => window.removeEventListener("hashchange", read);
+ }, []);
+
+ return (
@@ landing/src/components/Shell.tsx
@@ -0,0 +1,23 @@
+import Script from "next/script";
+import type { ReactNode } from "react";
+import { fontClass } from "@/app/fonts";
+import type { Lang } from "@/i18n";
+import "@/app/globals.css";
+
+// Shared <html>/<body> for every locale. Plausible is EU-hosted and cookieless;
+// data-domain is a placeholder until the domain is final.
+export function Shell({ lang, children }: { lang: Lang; children: ReactNode }) {
+ return (
+ <html lang={lang} className={fontClass}>
+ <body>
+ {children}
+ <Script
+ defer
+ data-domain="pop.dev"
+ src="https://plausible.io/js/script.tagged-events.js"
+ strategy="afterInteractive"
+ />
@@ landing/src/components/Terminal.tsx
@@ -0,0 +1,56 @@
+import type { TermLine, TermMock } from "./terminals";
+
+function Line({ line }: { line: TermLine }) {
+ switch (line.kind) {
+ case "blank":
+ return "\n";
+ case "cmd":
+ return (
+ <>
+ <span className="c">{line.text}</span>
+ {"\n"}
+ </>
+ );
+ case "dim":
+ return (
+ <>
+ <span className="d">{line.text}</span>
+ {"\n"}
+ </>
@@ landing/src/components/terminals.ts
@@ -0,0 +1,62 @@
+// Terminal mock content. The CLI speaks English in every locale (spec §6.1),
+// so these lines are shared by all three landing pages.
+
+export type TermLine =
+ | { kind: "cmd"; text: string }
+ | { kind: "out"; text: string }
+ | { kind: "dim"; text: string }
+ | { kind: "accent"; text: string }
+ | { kind: "prompt"; text: string }
+ | { kind: "blank" };
+
+export interface TermMock {
+ title: string;
+ lines: TermLine[];
+}
+
+export const termInstall: TermMock = {
+ title: "zsh — pop",
+ lines: [
@@ landing/src/i18n/de.ts
@@ -0,0 +1,64 @@
+import type { Dict } from "./types";
+
+export const de: Dict = {
+ lang: "de",
+ title: "pop — Zeig ihnen, wie du mit KI arbeitest",
+ description:
+ "pop zeichnet auf, wie dein Projekt tatsächlich entstanden ist: Entscheidungen, KI-Sitzungen, die Fehler, die du gefunden hast, und macht daraus eine Seite, die ein Hiring Manager in zwei Minuten liest.",
+ nav: { example: "Beispiel", how: "So funktioniert es", docs: "Dokumentation", github: "GitHub" },
+ hero: {
+ headline: "Zeig ihnen, wie du mit KI arbeitest. Nicht nur, was dabei entstanden ist.",
+ sub: "pop zeichnet auf, wie dein Projekt tatsächlich entstanden ist: Entscheidungen, KI-Sitzungen, die Fehler, die du gefunden hast, und macht daraus eine Seite, die ein Hiring Manager in zwei Minuten liest.",
+ tabs: { mac: "macOS / Linux", win: "Windows" },
+ copy: "Kopieren",
+ copied: "Kopiert",
+ projectNote: "Nur für ein Projekt? Führe den Befehl im Repository aus mit",
+ projectShow: "Befehl anzeigen",
+ projectHide: "Befehl ausblenden",
+ example: "Beispielseite ansehen",
+ },
@@ landing/src/i18n/en.ts
@@ -0,0 +1,64 @@
+import type { Dict } from "./types";
+
+export const en: Dict = {
+ lang: "en",
+ title: "pop — Show them how you work with AI",
+ description:
+ "pop records how your project actually came together, decisions, AI sessions, the bugs you caught, and turns it into a page a hiring manager reads in two minutes.",
+ nav: { example: "Example", how: "How it works", docs: "Docs", github: "GitHub" },
+ hero: {
+ headline: "Show them how you work with AI. Not just what it built.",
+ sub: "pop records how your project actually came together, decisions, AI sessions, the bugs you caught, and turns it into a page a hiring manager reads in two minutes.",
+ tabs: { mac: "macOS / Linux", win: "Windows" },
+ copy: "Copy",
+ copied: "Copied",
+ projectNote: "Only for one project? Run it inside the repo with",
+ projectShow: "show command",
+ projectHide: "hide command",
+ example: "See an example page",
+ },
@@ landing/src/i18n/hr.ts
@@ -0,0 +1,64 @@
+import type { Dict } from "./types";
+
+export const hr: Dict = {
+ lang: "hr",
+ title: "pop — Pokaži im kako radiš s AI-em",
+ description:
+ "pop bilježi kako je tvoj projekt stvarno nastao: odluke, AI sesije, greške koje si uhvatio, i od toga slaže stranicu koju voditelj zapošljavanja pročita u dvije minute.",
+ nav: { example: "Primjer", how: "Kako radi", docs: "Dokumentacija", github: "GitHub" },
+ hero: {
+ headline: "Pokaži im kako radiš s AI-em. Ne samo što je AI napravio.",
+ sub: "pop bilježi kako je tvoj projekt stvarno nastao: odluke, AI sesije, greške koje si uhvatio, i od toga slaže stranicu koju voditelj zapošljavanja pročita u dvije minute.",
+ tabs: { mac: "macOS / Linux", win: "Windows" },
+ copy: "Kopiraj",
+ copied: "Kopirano",
+ projectNote: "Samo za jedan projekt? Pokreni naredbu unutar repozitorija s",
+ projectShow: "prikaži naredbu",
+ projectHide: "sakrij naredbu",
+ example: "Pogledaj primjer stranice",
+ },
@@ landing/src/i18n/index.ts
@@ -0,0 +1,13 @@
+import { de } from "./de";
+import { en } from "./en";
+import { hr } from "./hr";
+import type { Dict, Lang } from "./types";
+
+export const dicts: Record<Lang, Dict> = { en, hr, de };
+
+export function isLang(x: string): x is Lang {
+ return x === "en" || x === "hr" || x === "de";
+}
+
+export type { Dict, Lang } from "./types";
+export { LANGS, LANG_PATH } from "./types";
@@ landing/src/i18n/types.ts
@@ -0,0 +1,32 @@
+export type Lang = "en" | "hr" | "de";
+
+export const LANGS: readonly Lang[] = ["en", "hr", "de"] as const;
+
+export const LANG_PATH: Record<Lang, string> = { en: "/", hr: "/hr/", de: "/de/" };
+
+export interface Dict {
+ lang: Lang;
+ title: string;
+ description: string;
+ nav: { example: string; how: string; docs: string; github: string };
+ hero: {
+ headline: string;
+ sub: string;
+ tabs: { mac: string; win: string };
+ copy: string;
+ copied: string;
+ projectNote: string;
+ projectShow: string;