diff --git a/.github/workflows/static-checks.yml b/.github/workflows/static-checks.yml new file mode 100644 index 0000000..04ab3f3 --- /dev/null +++ b/.github/workflows/static-checks.yml @@ -0,0 +1,21 @@ +name: Static checks + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Validate pages, assets, links, and JSON + run: python scripts/validate_site.py + diff --git a/README.md b/README.md index e68988f..a910d79 100644 --- a/README.md +++ b/README.md @@ -1,93 +1,51 @@ # Personal Portfolio -A personality-forward static portfolio for my projects, writing, and contact information. +Quinton's maintained public portfolio: a handcrafted static site about applied software systems, product thinking, and learning through projects. -The site uses a playful rope-and-cow visual theme while organizing project data separately from presentation code. It is intended to become the main public portfolio for SimpleCaci. +## Direction -> **Status:** active work in progress. The main pages, styling, project data, transitions, and custom cursor are present, but several sections still contain placeholder copy and links. +The design combines a technical field notebook with a restrained rope-and-ranch motif. It keeps the original cow mascot, but core navigation and content no longer depend on a custom cursor, animation, or JavaScript. -## Pages and features +The featured work spans geospatial mission data, computer vision, desktop product design, handwriting OCR, voice interaction, and browser-based pose estimation. Project status language is intentionally specific: a repository link is not presented as a verified deployment or demo. -- home page with animated hero and current-project cards -- project gallery populated from `data/projects.json` -- writings page -- contact page -- custom cursor, rope interaction, and page transitions -- responsive CSS structure and local visual assets +## Pages + +- `index.html` - homepage, selected work, method, about, and contact callout +- `projects/` - JSON-backed project field notes with no-JavaScript and load-failure fallbacks +- `writings/` - honest archive placeholder until finished writing exists +- `contacts/` - verified public email and GitHub links ## Technology - semantic HTML -- CSS -- vanilla JavaScript -- JSON-backed project/profile/writing data -- static hosting compatible - -## Project structure - -```text -index.html home -projects/ project gallery -writings/ writing links -contacts/ contact page -data/ profile, project, and writing content -js/ interactions and data rendering -css/ shared and page-specific styles -assets/ icons, project art, and visual theme -``` +- responsive CSS with custom properties +- small progressive-enhancement JavaScript +- JSON-backed project content +- dependency-free static validation ## Run locally -No build step is required. Use a local server so JSON requests and relative links behave consistently: +No build step is required. Serve the repository root so JSON and relative paths behave like GitHub Pages: ```bash python -m http.server 8000 ``` -Open [http://localhost:8000](http://localhost:8000). - -## Content updates - -Update portfolio content in: - -- `data/profile.json` -- `data/projects.json` -- `data/writings.json` +Open and visit every public page. -Keep claims, project status, screenshots, and links accurate. Do not add placeholder awards, metrics, or deployments. +## Validate -## Validation status - -No automated tests or CI workflow currently exist. Before publishing, manually check: - -- every navigation and project link -- desktop and mobile layouts -- keyboard focus and reduced-motion behavior -- image alternative text -- missing JSON/image failure states -- contact details and external links -- the site with JavaScript disabled or unavailable - -## Known unfinished work +```bash +python scripts/validate_site.py +``` -- the home page contains generic introduction/project copy -- writing entries include example placeholders -- the contact page references an external image -- navigation/footer sections are incomplete -- metadata and page titles are generic -- no deployment workflow or custom-domain documentation exists -- the custom cursor and animation effects need accessibility fallbacks +The script checks page structure, local links and images, alternative text, external-tab safety, obvious placeholders or encoding corruption, and the project JSON schema. GitHub Actions runs the same check for pull requests and pushes to `main`. -## Roadmap +## Deployment -- replace placeholder copy with a concise developer narrative -- feature the strongest verified projects and case studies -- add consistent project screenshots -- finish navigation and footer -- add reduced-motion and keyboard-friendly behavior -- validate and deploy the static site -- connect the profile README to the finished portfolio +The repository is associated with . Confirm the GitHub Pages source and live response after merging; the repository does not contain a custom deployment action. ## Authorship Designed and built by [SimpleCaci](https://github.com/SimpleCaci). A project license has not yet been selected. + diff --git a/contacts/index.html b/contacts/index.html index c0e835a..8aded9d 100644 --- a/contacts/index.html +++ b/contacts/index.html @@ -1,122 +1,12 @@ - - - - Contact - - - - - - - + Contact - Quinton - -
- -
- -
- - - -
- Top Bar Image -

/CONTACT

-
- -
-

Reach Out

- -

- Minimal. Direct. Human. -

- - -
-
-
email
- - simplesimpleemailemail@gmail.com - -
best for collabs & opportunities
-
- -
-
github
- - github.com/SimpleCaci - -
projects, experiments, code
-
-
- - -
-
- response time - 1–3 days -
-
- timezone - PST -
-
- status - open to ideas -
-
- - -

- cow-approved communication only. -

- - Cow QA -
- - -
- -
- -
- - - - - - - + + +

Contact / Open channel

Talk to a human, not a funnel.

If you want to discuss a project, a technical idea, or something useful that should exist, email is the simplest route. Public code and experiments live on GitHub.


Contact options

This page uses only contact information that was already public in the portfolio repository. There is no tracking script or third-party contact form.

Cow-approved communication
+ + + diff --git a/css/contacts.css b/css/contacts.css index c567021..13e82b4 100644 --- a/css/contacts.css +++ b/css/contacts.css @@ -1,300 +1,15 @@ -/* ===== FORCE CONTACT STYLES (wins over most existing CSS) ===== */ -.contacts{ - padding: 24px !important; - border-radius: 18px !important; - background: rgba(255,255,255,0.12) !important; - border: 1px solid rgba(255,255,255,0.22) !important; - backdrop-filter: blur(12px) !important; - box-shadow: 0 18px 44px rgba(0,0,0,0.18) !important; -} - -.contacts h1{ - margin: 0 0 12px 0 !important; - font-size: clamp(28px, 3.2vw, 44px) !important; - letter-spacing: -0.03em !important; -} - -.contacts .contacts-intro{ - margin: 0 0 14px 0 !important; - opacity: 0.85 !important; -} - -/* chips row */ -.contacts .chip-row{ - display: flex !important; - flex-wrap: wrap !important; - gap: 10px !important; - margin: 12px 0 14px 0 !important; -} - -.contacts .chip{ - border: 1px solid rgba(255,255,255,0.24) !important; - background: rgba(255,255,255,0.14) !important; - border-radius: 999px !important; - padding: 10px 12px !important; - cursor: pointer !important; - box-shadow: 0 12px 26px rgba(0,0,0,0.10) !important; -} - -.contacts .contact-form{ - display: grid !important; - gap: 14px !important; - padding: 18px !important; - border-radius: 16px !important; - background: rgba(255,255,255,0.12) !important; - border: 1px solid rgba(255,255,255,0.22) !important; - box-shadow: 0 14px 34px rgba(0,0,0,0.14) !important; -} - -.contacts .form-row{ display: grid !important; gap: 8px !important; } -.contacts .form-row-inline{ display: grid !important; grid-template-columns: 1fr 1fr !important; gap: 12px !important; } -.contacts .inline-field{ display: grid !important; gap: 8px !important; } - -.contacts label{ font-size: 13px !important; opacity: 0.82 !important; } - -.contacts input, -.contacts select, -.contacts textarea{ - width: 100% !important; - padding: 12px 12px !important; - border-radius: 14px !important; - border: 1px solid rgba(255,255,255,0.26) !important; - background: rgba(255,255,255,0.18) !important; - outline: none !important; - box-shadow: inset 0 1px 0 rgba(255,255,255,0.22), 0 10px 26px rgba(0,0,0,0.10) !important; -} - -.contacts textarea{ min-height: 140px !important; resize: vertical !important; } - -.contacts .char-row{ - display:flex !important; - justify-content: space-between !important; - align-items:center !important; - gap: 12px !important; - margin-top: 6px !important; -} - -.contacts .microcopy{ font-size: 12px !important; opacity: 0.72 !important; } - -.contacts .form-actions{ - display:flex !important; - justify-content: space-between !important; - align-items:center !important; - gap: 12px !important; - flex-wrap: wrap !important; -} - -.contacts .contact-submit{ - border: 0 !important; - cursor: pointer !important; - padding: 12px 16px !important; - border-radius: 14px !important; - font-weight: 700 !important; - background: linear-gradient(135deg, rgba(63,115,230,0.78), rgba(184,156,255,0.66)) !important; - box-shadow: 0 16px 36px rgba(0,0,0,0.18) !important; -} - -/* mini cards */ -.contacts .contacts-mini{ - display:grid !important; - grid-template-columns: repeat(3, minmax(0, 1fr)) !important; - gap: 12px !important; - margin-top: 14px !important; -} - -.contacts .mini-card{ - padding: 12px !important; - border-radius: 14px !important; - border: 1px solid rgba(255,255,255,0.22) !important; - background: rgba(255,255,255,0.10) !important; - box-shadow: 0 12px 28px rgba(0,0,0,0.12) !important; -} - -.contacts .mini-link{ font-weight: 700 !important; } - -/* cow */ -.contacts .work-in-progress-image{ - margin-top: 16px !important; - border-radius: 18px !important; - border: 1px solid rgba(255,255,255,0.22) !important; - box-shadow: 0 22px 50px rgba(0,0,0,0.22) !important; -} - -/* phone */ -@media (max-width: 820px){ - .contacts .form-row-inline{ grid-template-columns: 1fr !important; } - .contacts .contacts-mini{ grid-template-columns: 1fr !important; } - .contacts .contact-submit{ width: 100% !important; } -} - - -/* contacts.css — styling for the simplified /CONTACT page */ - -.contacts{ - width: min(980px, calc(100% - 48px)); - margin: 42px auto 120px; -} - -.contacts h1{ - margin: 0 0 10px 0; -} - -.contacts-intro{ - margin: 0 0 18px 0; - opacity: 0.85; - line-height: 1.55; -} - -/* Cards grid */ -.contact-cards{ - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 14px; - margin-top: 18px; -} - -.contact-card{ - padding: 16px 16px; - border-radius: 16px; - - background: rgba(255,255,255,0.08); - border: 1px solid rgba(255,255,255,0.16); - backdrop-filter: blur(10px); - - box-shadow: 0 14px 34px rgba(0,0,0,0.18); - transition: transform 180ms ease, box-shadow 180ms ease, border-color 180ms ease, background-color 180ms ease; -} - -.contact-card:hover{ - transform: translateY(-2px); - border-color: rgba(255,255,255,0.28); - box-shadow: 0 18px 46px rgba(0,0,0,0.26); - background: rgba(255,255,255,0.10); -} - -.contact-label{ - font-size: 0.92rem; - letter-spacing: 0.12em; - text-transform: uppercase; - opacity: 0.75; - margin-bottom: 10px; -} - -.contact-link{ - display: inline-block; - font-weight: 700; - text-decoration: none; - color: inherit; - - /* long emails wrap cleanly */ - overflow-wrap: anywhere; - - position: relative; - padding-bottom: 2px; -} - -/* crisp underline animation */ -.contact-link::after{ - content: ""; - position: absolute; - left: 0; - bottom: -2px; - height: 2px; - width: 100%; - transform: scaleX(0); - transform-origin: left; - transition: transform 180ms ease; - background: rgba(255,255,255,0.65); -} - -.contact-card:hover .contact-link::after{ - transform: scaleX(1); -} - -.contact-sub{ - margin-top: 10px; - opacity: 0.78; - line-height: 1.45; - font-size: 0.98rem; -} - -/* Meta row */ -.contact-meta{ - margin-top: 16px; - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 10px; -} - -.meta-item{ - padding: 12px 14px; - border-radius: 14px; - - background: rgba(255,255,255,0.06); - border: 1px solid rgba(255,255,255,0.12); - backdrop-filter: blur(10px); - - box-shadow: 0 10px 24px rgba(0,0,0,0.14); -} - -.meta-title{ - display: block; - font-size: 0.86rem; - letter-spacing: 0.10em; - text-transform: uppercase; - opacity: 0.72; - margin-bottom: 6px; -} - -.meta-value{ - display: block; - font-weight: 650; - opacity: 0.92; -} - -/* Cow image */ -.work-in-progress-image{ - width: 100%; - max-width: 760px; - margin: 18px auto 0; - display: block; - - border-radius: 18px; - border: 1px solid rgba(255,255,255,0.14); - box-shadow: 0 18px 50px rgba(0,0,0,0.22); - - /* makes it feel more “embedded” into your glass theme */ - filter: saturate(1.05) contrast(1.02); -} - -.contacts-small{ - margin-top: 14px; - opacity: 0.75; - line-height: 1.5; -} - -/* Mobile */ -@media (max-width: 720px){ - .contacts{ - width: min(980px, calc(100% - 28px)); - margin: 34px auto 110px; - } - - .contact-cards{ - grid-template-columns: 1fr; - } - - .contact-meta{ - grid-template-columns: 1fr; - } -} - -/* Reduced motion */ -@media (prefers-reduced-motion: reduce){ - .contact-card{ - transition: none; - } - .contact-link::after{ - transition: none; - } -} +.contact-intro { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(17rem, .55fr); gap: var(--s6); align-items: end; padding-block: var(--s7); } +.contact-intro h1 { max-width: 10ch; margin-bottom: 0; } +.contact-intro p:last-child { color: var(--muted); } +.contact-layout { display: grid; grid-template-columns: minmax(0, 1fr) 16rem; gap: var(--s6); padding-block: var(--s7); } +.contact-list { display: grid; gap: var(--s3); } +.contact-card { display: grid; grid-template-columns: 8rem 1fr auto; gap: var(--s4); align-items: center; min-height: 8rem; padding: var(--s4); border: 1px solid var(--line); border-radius: var(--radius); background: rgba(255, 250, 240, .48); } +.contact-card h2 { margin-bottom: 0; font-family: var(--serif); font-size: 1.8rem; } +.contact-card p { margin-bottom: 0; color: var(--muted); font-size: .85rem; } +.contact-card a { color: var(--rust); font: 700 .74rem var(--mono); } +.contact-mascot { align-self: start; margin: 0; padding: var(--s4); border: 1px solid var(--line); background: var(--paper-deep); text-align: center; transform: rotate(1.5deg); } +.contact-mascot img { aspect-ratio: 1; object-fit: contain; filter: grayscale(1) contrast(1.1); mix-blend-mode: multiply; } +.contact-mascot figcaption { font: .61rem var(--mono); text-transform: uppercase; } +.privacy-note { max-width: var(--reading); margin-top: var(--s5); padding-top: var(--s3); border-top: 1px solid var(--line); color: var(--muted); font-size: .8rem; } +@media (max-width: 48rem) { .contact-intro, .contact-layout { grid-template-columns: 1fr; } .contact-card { grid-template-columns: 1fr; gap: .45rem; } .contact-mascot { display: none; } } + diff --git a/css/global.css b/css/global.css index f0c53cc..0bf5bf3 100644 --- a/css/global.css +++ b/css/global.css @@ -1,468 +1,84 @@ -/* ========================= - GLOBAL RESET / BASELINE - ========================= */ -*, -*::before, -*::after { box-sizing: border-box; } - -html, body { height: 100%; } - -body { - margin: 0; - font-family: var(--font-main); - font-size: 16px; - letter-spacing: -0.01em; - color: var(--color-text-primary); - background-color: var(--color-primary); - - /* custom cursor */ - cursor: none; -} - -img, svg, video, canvas { - display: block; - max-width: 100%; - height: auto; -} - -:focus-visible { - outline: 2px solid rgba(255,255,255,0.55); - outline-offset: 3px; - border-radius: 10px; -} - -/* ========================= - ROOT TOKENS - ========================= */ :root { - /* Primary Palette */ - --color-primary: #85b9f2; - --color-secondary: #3d73e6; - --color-accent: #918ce0; - - /* Shades of Gray */ - --gray-light: #e9ecef; - --gray-medium: #6c757d; - --gray-dark: #343a40; - - /* Utility/Status Colors */ - --color-success: #28a745; - --color-warning: #ffc107; - --color-error: #dc3545; - - /* UI Tokens */ - --color-text-primary: #0e1630; - --color-hover-background: rgba(255, 255, 255, 0.14); - - /* font */ - --font-main: "Space Grotesk", system-ui, sans-serif; - - /* layout / polish */ - --page-max: 1120px; - --page-pad: clamp(16px, 3vw, 32px); - --frame-pad: clamp(12px, 2vw, 24px); - - --card-pad: clamp(16px, 2.5vw, 28px); - --card-gap: clamp(14px, 2vw, 22px); - - --radius-card: 16px; - --radius-hero: 18px; - - --glass: rgba(255, 255, 255, 0.10); - --glass-hover: rgba(255, 255, 255, 0.12); - --glass-border: rgba(255, 255, 255, 0.18); - --glass-border-hover: rgba(255, 255, 255, 0.30); - - --shadow-card: 0 10px 26px rgba(0, 0, 0, 0.18); - --shadow-card-hover: 0 16px 40px rgba(0, 0, 0, 0.24); - - --shadow-hero: 0 18px 48px rgba(0,0,0,0.28); - --shadow-hero-hover: 0 22px 60px rgba(0,0,0,0.34); - - --transition-fast: 220ms ease; - --transition-med: 260ms ease; - - /* HERO IMAGE (cloud banner) */ - --hero-bg-url: url("../assets/images/clouds-banner.png"); -} - -/* ========================= - TYPE - ========================= */ -h1 { - margin: 0 0 0.35em 0; - font-size: clamp(30px, 3.2vw, 44px); - font-weight: 700; - letter-spacing: -0.03em; -} - -h2 { - margin: 0 0 0.45em 0; - font-size: clamp(20px, 2.1vw, 28px); - font-weight: 600; -} - -p { - margin: 0 0 1em 0; - font-size: 16px; - line-height: 1.6; -} - -/* ========================= - CUSTOM CURSOR (works with your JS) - cursor div can be inside main; fixed positioning ignores that - ========================= */ -#cursor { - position: fixed; - left: 0; - top: 0; - - width: 18px; - height: 18px; - border-radius: 999px; - - background: rgba(255, 255, 255, 0.78); - box-shadow: 0 12px 28px rgba(0,0,0,0.18); - backdrop-filter: blur(8px); - - pointer-events: none; - z-index: 999999; - - /* JS sets left/top + transform scale on hover */ - transform: translate(-50%, -50%) scale(1); - transition: transform 140ms ease, opacity 140ms ease, width 140ms ease, height 140ms ease; - - /* subtle breathing animation */ - animation: cursorPulse 2.4s ease-in-out infinite; -} - -@keyframes cursorPulse { - 0% { filter: drop-shadow(0 0 0 rgba(255,255,255,0)); } - 50% { filter: drop-shadow(0 0 10px rgba(255,255,255,0.35)); } - 100% { filter: drop-shadow(0 0 0 rgba(255,255,255,0)); } -} - -/* optional: hide cursor when leaving window */ -body.is-out #cursor { opacity: 0; } - -/* ========================= - LAYOUT - ========================= */ -main { - padding: var(--page-pad); -} - -.main-frame { - max-width: var(--page-max); - margin: 0 auto; - padding-inline: var(--frame-pad); -} - -/* ========================= - AUTO-CARDS (keep your behavior) - everything inside main becomes a “card” - EXCEPT the hero - ========================= */ -main > div:not(.top-bar-section) { - margin: 0 0 var(--card-gap) 0; - padding: var(--card-pad); - - border: 1px solid var(--glass-border); - border-radius: var(--radius-card); - - background: var(--glass); - backdrop-filter: blur(10px); - - box-shadow: var(--shadow-card); - - transition: - transform var(--transition-fast), - box-shadow var(--transition-fast), - background-color var(--transition-fast), - border-color var(--transition-fast); -} - -main > div:not(.top-bar-section):hover { - transform: translateY(-3px); - box-shadow: var(--shadow-card-hover); - border-color: var(--glass-border-hover); - background: var(--glass-hover); -} - -main > div > *:first-child { margin-top: 0; } -main > div > *:last-child { margin-bottom: 0; } - -@media (prefers-reduced-motion: reduce) { - #cursor { animation: none; transition: none; } - main > div:not(.top-bar-section) { transition: none; } - main > div:not(.top-bar-section):hover { transform: none; } -} - -/* ========================= - HERO (BANNER-SIZED, NOT FULLSCREEN) - ========================= */ -.top-bar-section { - width: 100%; - - /* banner sizing: prevents “giant full-screen hero” */ - min-height: unset; - height: clamp(220px, 34vh, 420px); - - margin: 0 0 var(--card-gap) 0; - - border-radius: var(--radius-hero); - overflow: hidden; - - border: 1px solid rgba(255, 255, 255, 0.14); - box-shadow: var(--shadow-hero); - - position: relative; - - /* dreamy cloud banner */ - background: var(--hero-bg-url) center / cover no-repeat; - - transition: - transform var(--transition-med), - box-shadow var(--transition-med), - border-color var(--transition-med); - - display: flex; /* Enable flexbox */ - align-items: center; /* Vertical centering */ - justify-content: center; /* Optional: horizontal centering */ - border: 1px solid #ccc; /* Just for visibility */ -} - -/* subtle parallax drift using background-position */ -@keyframes cloudDrift { - 0% { background-position: 50% 45%; } - 50% { background-position: 52% 48%; } - 100% { background-position: 50% 45%; } -} - -.top-bar-section { - animation: cloudDrift 14s ease-in-out infinite; - display: flex; - align-items: center; - justify-content: center; - text-align: center; -} - -.top-bar-section > *{ - margin:0%; - border: 0%; /* kills default h1 margin pushing things */ -} - -.top-bar-section h1{ - margin: 0; - line-height: 1; - font-size: clamp(42px, 7vw, 120px); /* adjust max if you want smaller */ -} - -.top-bar-section:hover { - transform: translateY(-2px); - box-shadow: var(--shadow-hero-hover); - border-color: rgba(255, 255, 255, 0.35); -} - -/* soft overlay glow (no layout impact) */ -.top-bar-section::before { - content: ""; - position: absolute; - inset: 0; - pointer-events: none; - - background: - radial-gradient(900px 420px at 50% 30%, rgba(255,255,255,0.22), transparent 65%), - radial-gradient(900px 520px at 50% 120%, rgba(61,115,230,0.12), transparent 65%); - - mix-blend-mode: screen; - opacity: 0.85; -} - -/* optional: a gentle vignette for readability if text is on hero */ -.top-bar-section::after { - content: ""; - position: absolute; - inset: 0; - pointer-events: none; - - background: linear-gradient( - to bottom, - rgba(0,0,0,0.06), - rgba(0,0,0,0.12) - ); - - opacity: 0.55; - -} -/* ========================= - NAVBAR - ========================= */ -.top-nav { - height: 4rem; - display: flex; - align-items: center; - justify-content: center; -} - -.top-nav__inner { - display: flex; - align-items: center; - justify-content: center; -} - -.top-nav__list { - list-style: none; - margin: 0; - padding: 0; - - display: flex; - align-items: center; - gap: 0.5rem; -} - -.top-nav__item { margin: 0; padding: 0; } - -.top-nav__link { - color: var(--color-text-primary); - text-decoration: none; - - display: inline-flex; - align-items: center; - gap: 0.5rem; - - padding: 0.5rem 0.7rem; - border-radius: 0.75rem; - - font-size: 0.875rem; - font-weight: 500; - - transition: color 200ms ease, background-color 200ms ease, box-shadow 200ms ease, transform 200ms ease; -} - -.top-nav__link:hover { - color: var(--gray-light); - background: var(--color-hover-background); - box-shadow: 0 12px 28px rgba(0, 0, 0, 0.22); - transform: translateY(-1px); -} - -.top-nav__icon { - width: 20px; - height: 20px; - flex: 0 0 auto; - display: block; - fill: currentColor; -} - -.top-nav__label { display: none; } - -@media (min-width: 768px) { - .top-nav__list { gap: 1rem; } - .top-nav__label { display: inline; } -} - -/* ========================= - ROPE (pull cord) - ========================= */ -@keyframes sway { - 0% { transform: translate(-50%, 0px) rotate(-2deg); } - 50% { transform: translate(-50%, 0px) rotate(2deg); } - 100% { transform: translate(-50%, 0px) rotate(-2deg); } -} - -#rope { - position: fixed; - top: 0; - right: 3%; - - transform: translate(-50%, 0px); - transform-origin: top center; - - z-index: 10000; - - width: min(70px, 5vw); - height: auto; - object-fit: contain; - - opacity: 1; - display: block; - pointer-events: auto; - cursor: grab; - - filter: drop-shadow(0 6px 6px rgba(0,0,0,0.25)); - transition: filter var(--transition-fast); -} - -#rope:hover { - filter: drop-shadow(0 10px 16px rgba(0,0,0,0.35)); -} - -#rope:active { - cursor: grabbing; - filter: drop-shadow(0 8px 14px rgba(237, 198, 69, 0.65)); -} - -/* optional idle sway */ -#rope.idle-sway { - animation: sway 2.8s ease-in-out infinite; -} - -/* ========================= - WORK IN PROGRESS IMAGE - ========================= */ -.work-in-progress-image { - border-top: 20px solid var(--color-error); - border-bottom: 20px solid var(--color-error); - border-radius: 13px; - - width: min(560px, 70%); - height: auto; - - display: block; - margin-left: auto; - margin-right: auto; - - transition: transform 240ms ease, filter 240ms ease; -} - -.work-in-progress-image:hover { - transform: scale(1.06); - filter: drop-shadow(0 12px 22px rgba(0,0,0,0.25)); -} - -/* ========================= - PAGE TRANSITION OVERLAY - ========================= */ -#wipe { - position: fixed; - inset: 0; - z-index: 9999; - pointer-events: none; - - margin: 0; - padding: 0; - - display: grid; - gap: 0; - - background: transparent !important; - opacity: 0; -} - -#wipe .bar { - margin: 0; - padding: 0; - border: 0; - border-radius: 0; - - width: 100%; - height: 100%; - - background: #0b0b0b !important; - transform: scaleX(0); - will-change: transform; -} - - - + --ink: #171b16; + --paper: #f3eddf; + --paper-deep: #e5dac4; + --surface: #fffaf0; + --text: #20251f; + --muted: #5f685e; + --moss: #536b4d; + --rust: #a44f32; + --blue: #547183; + --rope: #a97842; + --line: rgba(32, 37, 31, 0.22); + --sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --serif: Georgia, "Times New Roman", serif; + --mono: "Courier New", ui-monospace, monospace; + --content: 76rem; + --reading: 45rem; + --s1: .4rem; + --s2: .7rem; + --s3: 1rem; + --s4: 1.5rem; + --s5: 2.3rem; + --s6: 3.6rem; + --s7: 5.6rem; + --radius: .55rem; + --shadow: 0 1.25rem 3rem rgba(55, 42, 25, .09); + --focus: 0 0 0 .25rem rgba(164, 79, 50, .3); +} +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +body { margin: 0; background: var(--paper); color: var(--text); font: 1rem/1.65 var(--sans); -webkit-font-smoothing: antialiased; } +body::before { position: fixed; inset: 0; z-index: -1; content: ""; opacity: .045; pointer-events: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='3'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); } +img { display: block; max-width: 100%; height: auto; } +a { color: inherit; text-underline-offset: .2em; } +a:hover { text-decoration-color: var(--rust); } +a:focus-visible, button:focus-visible, summary:focus-visible, select:focus-visible { outline: 0; box-shadow: var(--focus); } +button, select { font: inherit; } +p, h1, h2, h3, dl, figure { margin-top: 0; } +h1, h2, h3 { line-height: 1.08; text-wrap: balance; } +h1, h2 { font-family: var(--serif); font-weight: 400; letter-spacing: -.035em; } +h1 { font-size: clamp(3rem, 8vw, 7.4rem); } +h2 { font-size: clamp(2.2rem, 5vw, 4.4rem); } +h3 { font-size: clamp(1.25rem, 2vw, 1.75rem); } +.skip-link { position: fixed; top: 1rem; left: 1rem; z-index: 100; padding: .75rem 1rem; background: var(--ink); color: var(--paper); transform: translateY(-180%); } +.skip-link:focus { transform: none; } +.site-header, .site-footer, .page-shell { width: min(calc(100% - 2rem), var(--content)); margin-inline: auto; } +.site-header { display: flex; min-height: 5.25rem; align-items: center; gap: var(--s5); border-bottom: 1px solid var(--line); } +.wordmark { display: inline-flex; align-items: center; gap: .7rem; margin-right: auto; color: var(--text); font-weight: 750; text-decoration: none; } +.wordmark-mark { display: grid; width: 2rem; height: 2rem; place-items: center; border-radius: 50%; background: var(--ink); color: var(--paper); font: 700 .75rem var(--mono); } +.desktop-nav { display: flex; align-items: center; gap: 1.35rem; } +.desktop-nav a, .mobile-nav a { color: var(--muted); font-size: .81rem; font-weight: 650; text-decoration: none; } +.desktop-nav a:hover, .desktop-nav a[aria-current="page"], .mobile-nav a[aria-current="page"] { color: var(--rust); } +.nav-github { padding-left: 1.35rem; border-left: 1px solid var(--line); } +.mobile-nav { display: none; position: relative; } +.mobile-nav summary { display: grid; min-width: 2.8rem; min-height: 2.8rem; place-items: center; border: 1px solid var(--line); border-radius: var(--radius); cursor: pointer; font: 700 .7rem var(--mono); list-style: none; } +.mobile-nav summary::-webkit-details-marker { display: none; } +.mobile-nav-menu { position: absolute; top: calc(100% + .6rem); right: 0; z-index: 20; display: grid; min-width: 13rem; padding: .55rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); } +.mobile-nav-menu a { padding: .8rem; } +.eyebrow, .annotation, .status-label, .tag-list li { font-family: var(--mono); font-size: .68rem; letter-spacing: .075em; text-transform: uppercase; } +.eyebrow { margin-bottom: var(--s3); color: var(--rust); } +.section-heading { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(16rem, .6fr); gap: var(--s6); align-items: end; margin-bottom: var(--s6); } +.section-heading h2, .section-heading > p { margin-bottom: 0; } +.section-heading > p { color: var(--muted); } +.button-row { display: flex; flex-wrap: wrap; gap: var(--s2); } +.button { display: inline-flex; min-height: 2.9rem; align-items: center; justify-content: center; padding: .7rem 1rem; border: 1px solid var(--ink); border-radius: var(--radius); font-size: .82rem; font-weight: 700; text-decoration: none; transition: transform 160ms, background 160ms, color 160ms; } +.button:hover { transform: translateY(-2px); } +.button-primary { background: var(--ink); color: var(--paper); } +.button-primary:hover { background: var(--rust); } +.text-link { color: var(--rust); font: 700 .74rem var(--mono); } +.tag-list { display: flex; flex-wrap: wrap; gap: .42rem; margin: 0; padding: 0; list-style: none; } +.tag-list li { padding: .32rem .5rem; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); font-size: .6rem; } +.status-label { color: var(--moss); } +.rope-rule { height: .5rem; border: 0; background: url("../assets/images/rope.png") center 47% / 4rem auto repeat-x; filter: sepia(.45) saturate(.65); opacity: .4; } +.empty-state, .error-state { padding: var(--s5); border: 1px dashed var(--rope); border-radius: var(--radius); background: rgba(255, 250, 240, .58); } +.error-state { border-color: var(--rust); } +.visually-hidden { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; overflow: hidden !important; clip: rect(0, 0, 0, 0) !important; white-space: nowrap !important; border: 0 !important; } +.site-footer { display: grid; grid-template-columns: 1fr auto; gap: var(--s4); padding-block: var(--s5); border-top: 1px solid var(--line); color: var(--muted); font-size: .77rem; } +.footer-nav { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--s3); } +.footer-note { margin: 0; } +@media (max-width: 48rem) { .desktop-nav { display: none; } .mobile-nav { display: block; } .site-header { min-height: 4.5rem; } .section-heading { grid-template-columns: 1fr; gap: var(--s2); } .site-footer { grid-template-columns: 1fr; } .footer-nav { justify-content: flex-start; } } +@media (max-width: 24rem) { .site-header, .site-footer, .page-shell { width: min(calc(100% - 1.25rem), var(--content)); } .button-row { display: grid; } } +@media (prefers-contrast: more) { :root { --line: rgba(19, 23, 18, .5); --muted: #394038; } .button, .tag-list li { border-width: 2px; } } +@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } *, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; } } + diff --git a/css/index.css b/css/index.css index 32dcf84..2e61176 100644 --- a/css/index.css +++ b/css/index.css @@ -1,55 +1,46 @@ -/* index.html */ -.top-bar-section { - position: relative; - display: inline-block; -} - -.top-bar-section img { - display: block; - width: 100%; - height: auto; -} - -.top-bar-section .page-title { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -100%); - color: var(--color-primary); - font-size: 9cqw; - font-weight: bold; - text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.7); -} - - - -/* others-sections */ -.other-sections-section { - display: flex; - flex-wrap: wrap; - justify-content: center; - container-type:inline-size; /* needed for cqw */ -} - - -.other-sections-section a{ /* to treat the div like a block */ - display:block; - text-decoration:none; - color:inherit; -} - -.other-sections-section a div { - margin: 10px; - padding: 20px; - font-size: 3.3cqw; - transition: background-color 0.2s ease, font-size 0.2s ease; -} - -.other-sections-section a:hover div { - font-size: 4cqw; - background-color: var(--color-primary); -} - -.other-sections-section a:active div { - filter: drop-shadow(0 0 10px gold); -} +.hero { position: relative; display: grid; min-height: calc(100vh - 5.25rem); grid-template-columns: minmax(0, 1.55fr) minmax(17rem, .55fr); gap: clamp(2rem, 7vw, 7rem); align-items: center; padding-block: var(--s7); } +.hero::after { position: absolute; right: 1.5rem; bottom: 1.1rem; color: var(--muted); content: "FIELD NOTE 01 / APPLIED SYSTEMS"; font: .61rem var(--mono); letter-spacing: .08em; } +.hero h1 { max-width: 12ch; margin-bottom: var(--s4); } +.hero-lead { max-width: 43rem; color: var(--muted); font-size: clamp(1.05rem, 1.7vw, 1.3rem); } +.hero-card { position: relative; padding: var(--s4); border: 1px solid var(--line); border-radius: var(--radius); background: rgba(255, 250, 240, .58); box-shadow: var(--shadow); transform: rotate(1deg); } +.hero-card::before { position: absolute; top: -.45rem; left: 50%; width: 5rem; height: 1rem; background: rgba(173, 135, 83, .24); content: ""; transform: translateX(-50%) rotate(-2deg); } +.field-map { position: relative; height: 13rem; margin-bottom: var(--s3); border: 1px solid var(--line); background-image: linear-gradient(var(--line) 1px, transparent 1px), linear-gradient(90deg, var(--line) 1px, transparent 1px); background-size: 2rem 2rem; overflow: hidden; } +.field-map::before { position: absolute; inset: 20% 12%; border: 2px dashed var(--rope); border-radius: 50%; content: ""; transform: rotate(-18deg); } +.field-map::after { position: absolute; top: 31%; left: 28%; width: .65rem; height: .65rem; border-radius: 50%; background: var(--rust); box-shadow: 0 0 0 .3rem rgba(164, 79, 50, .18); content: ""; } +.field-list { margin: 0; } +.field-list div { display: grid; grid-template-columns: 4.6rem 1fr; gap: var(--s2); padding-block: .55rem; border-top: 1px solid var(--line); } +.field-list dt { color: var(--muted); font: .61rem var(--mono); text-transform: uppercase; } +.field-list dd { margin: 0; font-size: .77rem; } +.home-section { padding-block: var(--s7); border-top: 1px solid var(--line); } +.featured { display: grid; grid-template-columns: 3rem minmax(0, 1.25fr) minmax(15rem, .6fr); gap: var(--s4); padding-block: var(--s5); border-block: 1px solid var(--line); } +.featured h3 { margin-bottom: var(--s2); font: 400 clamp(2rem, 4vw, 3.9rem) var(--serif); } +.project-number { color: var(--rust); font: .74rem var(--mono); } +.system-flow { display: flex; flex-wrap: wrap; align-items: center; gap: .55rem; margin-block: var(--s4); color: var(--rope); font: .64rem var(--mono); } +.system-flow span { padding: .45rem .65rem; border: 1px solid var(--line); color: var(--text); } +.project-facts { padding-left: var(--s4); border-left: 1px solid var(--line); } +.project-facts p { padding-bottom: var(--s3); border-bottom: 1px solid var(--line); color: var(--muted); font-size: .79rem; } +.project-facts strong { display: block; margin-bottom: .2rem; color: var(--text); font: .61rem var(--mono); text-transform: uppercase; } +.preview-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--s3); margin-top: var(--s3); } +.preview { --accent: var(--moss); display: flex; min-height: 23rem; flex-direction: column; padding: var(--s4); border: 1px solid var(--line); border-top: .3rem solid var(--accent); border-radius: var(--radius); background: rgba(255, 250, 240, .45); } +.preview:nth-child(2) { --accent: var(--blue); } .preview:nth-child(3) { --accent: var(--rope); } .preview:nth-child(4) { --accent: var(--rust); } +.preview h3 { margin: var(--s5) 0 var(--s2); font: 400 2rem var(--serif); } +.preview > p { color: var(--muted); } +.preview .tag-list { margin-top: auto; margin-bottom: var(--s4); } +.method-grid { display: grid; grid-template-columns: repeat(4, 1fr); margin: 0; padding: 0; border-top: 1px solid var(--line); list-style: none; } +.method-grid li { min-height: 15rem; padding: var(--s4); border-right: 1px solid var(--line); } +.method-grid li:last-child { border-right: 0; } +.method-grid span { color: var(--rust); font: .67rem var(--mono); } +.method-grid h3 { margin-top: var(--s5); } +.method-grid p { color: var(--muted); font-size: .81rem; } +.about-grid { display: grid; grid-template-columns: 1fr 1fr 10rem; gap: var(--s6); align-items: start; } +.about-copy { color: var(--muted); font-size: 1.03rem; } +.about-note { padding-top: var(--s3); border-top: 1px solid var(--line); color: var(--rust); font: .69rem var(--mono); } +.mascot { margin: 0; padding: var(--s3); border: 1px solid var(--line); background: var(--paper-deep); text-align: center; transform: rotate(2deg); } +.mascot img { aspect-ratio: 1; object-fit: contain; filter: grayscale(1) contrast(1.1); mix-blend-mode: multiply; } +.mascot figcaption { font: .59rem var(--mono); text-transform: uppercase; } +.home-contact { max-width: 50rem; margin-inline: auto; padding-block: var(--s7); text-align: center; } +.home-contact > p:not(.eyebrow) { max-width: 36rem; margin-inline: auto; color: var(--muted); } +.home-contact .button-row { justify-content: center; } +@media (max-width: 60rem) { .hero { min-height: auto; grid-template-columns: 1fr; } .hero-card { max-width: 32rem; } .featured { grid-template-columns: 2rem 1fr; } .project-facts { grid-column: 2; padding-left: 0; border-left: 0; } .method-grid { grid-template-columns: repeat(2, 1fr); } .about-grid { grid-template-columns: 1fr 1fr; } .mascot { display: none; } } +@media (max-width: 40rem) { .hero { padding-block: var(--s6); } .hero::after, .hero-card { display: none; } .featured { grid-template-columns: 1fr; } .project-number { display: none; } .project-facts { grid-column: 1; } .preview-grid, .method-grid, .about-grid { grid-template-columns: 1fr; } .method-grid li { min-height: 0; border-right: 0; border-bottom: 1px solid var(--line); } } + diff --git a/css/projects.css b/css/projects.css index 090bcb0..d590352 100644 --- a/css/projects.css +++ b/css/projects.css @@ -1,426 +1,35 @@ -/*projects/index.html*/ - -/* ========================= - DATE SIDEBAR -========================= */ -.date-sidebar{ - position: fixed; - top: 30%; - right: 24px; - z-index: 999; - max-width: 190px; - - padding: 12px 14px; - border-radius: 14px; - - background: rgba(255,255,255,0.10); - border: 1px solid rgba(255,255,255,0.18); - backdrop-filter: blur(10px); - - color: var(--gray-light); - box-shadow: 0 14px 34px rgba(0,0,0,0.25); - - transition: transform 220ms ease, box-shadow 220ms ease, border-color 220ms ease, background-color 220ms ease; -} - -.date-sidebar:hover{ - transform: translateY(-2px); - box-shadow: 0 18px 46px rgba(0,0,0,0.32); - border-color: rgba(255,255,255,0.28); - background: rgba(255,255,255,0.12); -} - -.date-sidebar-label{ - font-size: 0.85rem; - opacity: 0.75; - margin: 0 0 6px 0; - letter-spacing: -0.01em; -} - -.date-sidebar-date{ - font-size: 1.05rem; - font-weight: 700; - margin: 0; - letter-spacing: -0.02em; -} - -/* optional: hide on small screens so it doesn't cover content */ -@media (max-width: 760px){ - .date-sidebar{ display:none; } -} - -/* ========================= - GRID LAYOUT (SIDE-BY-SIDE) -========================= */ -.current-items-section{ - display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - gap: 18px; - align-items: stretch; - padding: 10px; -} - -/* ========================= - PROJECT CARD (COMPACT + YUMMY) -========================= */ -.project-card{ - display: flex; - flex-direction: column; - gap: 12px; - - padding: 1.2rem; - margin: 0; /* grid handles spacing */ - border-radius: 16px; - - background: rgba(255,255,255,0.08); - border: 1px solid rgba(255,255,255,0.18); - backdrop-filter: blur(10px); - - box-shadow: 0 12px 28px rgba(0,0,0,0.18); - transition: transform 220ms ease, box-shadow 220ms ease, border-color 220ms ease, background-color 220ms ease; -} - -.project-card:hover{ - transform: translateY(-6px); - box-shadow: 0 20px 46px rgba(0,0,0,0.28); - border-color: rgba(125, 211, 252, 0.35); - background: rgba(255,255,255,0.095); -} - -/* ========================= - HEADER -========================= */ -.project-header{ - display: flex; - flex-direction: column; - gap: 6px; -} - -.project-header h2{ - margin: 0; - font-size: 1.25rem; - letter-spacing: -0.02em; - line-height: 1.15; -} - -.project-meta{ - margin: 0; - font-size: 0.85rem; - opacity: 0.7; -} - -/* ========================= - IMAGE -========================= */ -.project-image-wrap{ - border-radius: 12px; - overflow: hidden; - border: 1px solid rgba(255,255,255,0.10); -} - -.project-image{ - width: 100%; - height: 160px; - object-fit: cover; - display: block; - transition: transform 300ms ease; -} - -.project-card:hover .project-image{ - transform: scale(1.04); -} - -/* ========================= - TECH ICONS ROW -========================= */ -.project-tech-icons{ - display: flex; - flex-wrap: wrap; - gap: 8px; - align-items: center; -} - -.tech-chip{ - display: inline-flex; - align-items: center; - justify-content: center; - height: 28px; - min-width: 28px; - padding: 0 8px; - border-radius: 999px; - - background: rgba(255,255,255,0.07); - border: 1px solid rgba(255,255,255,0.10); -} - -.tech-icon{ - width: 18px; - height: 18px; - object-fit: contain; - display: block; -} - -/* if icon missing, we show text */ -.tech-chip--text{ - font-size: 0.85rem; - opacity: 0.9; -} - -/* ========================= - BODY -========================= */ -.project-body{ - display: flex; - flex-direction: column; - gap: 10px; -} - -.project-desc{ - margin: 0; - line-height: 1.45; - font-size: 0.95rem; - opacity: 0.92; -} - -/* tighten the detail lines */ -.project-body p{ - margin: 0.35rem 0; - line-height: 1.5; -} - -/* optional: make the long “Problem/Approach/Result/Learned” block denser */ -.project-details{ - display: flex; - flex-direction: column; - gap: 6px; -} - -.project-details p{ - margin: 0; - font-size: 0.9rem; - opacity: 0.88; -} - -.project-details strong{ - opacity: 1; -} - -/* ========================= - FOOTER -========================= */ -.project-footer{ - margin-top: auto; - display: flex; - justify-content: flex-end; -} - -.project-link{ - color: var(--color-accent); - font-weight: 600; - text-decoration: none; - - padding: 8px 10px; - border-radius: 10px; - - background: rgba(125, 211, 252, 0.14); - border: 1px solid rgba(125, 211, 252, 0.25); - - transition: opacity 150ms ease, background-color 150ms ease, border-color 150ms ease, transform 150ms ease; -} - -.project-link:hover{ - opacity: 0.9; - background: rgba(125, 211, 252, 0.20); - border-color: rgba(125, 211, 252, 0.40); - transform: translateY(-1px); -} - -/* ========================= - NICE KEYBOARD FOCUS -========================= */ -.project-card:focus-within{ - outline: none; - border-color: rgba(145,140,224,0.40); - box-shadow: - 0 18px 44px rgba(0,0,0,0.28), - 0 0 0 3px rgba(145,140,224,0.22); -} - -/* ========================= - RESPONSIVE TUNING -========================= */ -@media (max-width: 420px){ - .current-items-section{ - grid-template-columns: 1fr; - } - .project-image{ - height: 140px; - } - .project-card{ - padding: 1rem; - } -} - -/* ========================= - REDUCE MOTION -========================= */ -@media (prefers-reduced-motion: reduce){ - .date-sidebar, - .project-card, - .project-image, - .project-link{ - transition: none; - } - - .date-sidebar:hover, - .project-card:hover{ - transform: none; - filter: none; - } -} - - - - - - - - - - - - - -/* Add to ../css/projects.css (or move to a dedicated writings.css if you want) */ - -.writings{ - width: min(980px, calc(100% - 48px)); - margin: 42px auto 120px; -} - -.writings-header{ - margin-bottom: 18px; -} - -.writings-header h1{ - margin: 0 0 8px 0; -} - -.writings-header p{ - margin: 0; - opacity: 0.85; - line-height: 1.5; -} - -.writings-list{ - list-style: none; - padding: 0; - margin: 18px 0 0 0; - - display: grid; - gap: 14px; -} - -.writing-item{ - margin: 0; -} - -.writing-link{ - display: grid; - grid-template-columns: 1fr auto; - gap: 14px; - align-items: center; - - padding: 14px 16px; - border-radius: 16px; - - text-decoration: none; - color: inherit; - - background: rgba(255,255,255,0.08); - border: 1px solid rgba(255,255,255,0.16); - backdrop-filter: blur(10px); - - transition: transform 180ms ease, box-shadow 180ms ease, border-color 180ms ease, background-color 180ms ease; - box-shadow: 0 14px 34px rgba(0,0,0,0.18); -} - -.writing-link:hover{ - transform: translateY(-2px); - border-color: rgba(255,255,255,0.28); - box-shadow: 0 18px 46px rgba(0,0,0,0.26); - background: rgba(255,255,255,0.10); -} - -.writing-link:active{ - transform: translateY(-1px); -} - -.writing-main{ - display: grid; - gap: 6px; - min-width: 0; -} - -.writing-title{ - font-weight: 700; - letter-spacing: 0.2px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.writing-desc{ - opacity: 0.82; - line-height: 1.35; - - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; -} - -.writing-meta{ - opacity: 0.75; - font-size: 0.92rem; - white-space: nowrap; - padding-left: 10px; -} - -@media (max-width: 640px){ - .writing-link{ - grid-template-columns: 1fr; - } - .writing-meta{ - padding-left: 0; - } -} - - -.tech-chip{ - position:relative; - display:inline-flex; - align-items:center; - justify-content:center; -} - -.tech-chip::after{ - content:attr(data-label); - position:absolute; - left:50%; - bottom:calc(100% + 8px); - transform:translateX(-50%); - background:rgba(0,0,0,.85); - color:#fff; - font-size:12px; - padding:3px 8px; - border-radius:6px; - white-space:nowrap; - opacity:0; - pointer-events:none; - z-index:9999; -} - -.tech-chip:hover::after{ - opacity:1; -} +.page-intro { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(16rem, .6fr); gap: var(--s6); align-items: end; padding-block: var(--s7); } +.page-intro h1 { margin-bottom: 0; } +.page-intro-copy { color: var(--muted); } +.project-gallery { padding-bottom: var(--s7); } +.project-list { display: grid; gap: var(--s4); } +.case-card { --accent: var(--moss); display: grid; grid-template-columns: minmax(11rem, .38fr) minmax(0, 1fr); border: 1px solid var(--line); border-radius: var(--radius); background: rgba(255, 250, 240, .5); overflow: hidden; box-shadow: var(--shadow); } +.case-card:nth-child(2n) { --accent: var(--blue); } +.case-card:nth-child(3n) { --accent: var(--rust); } +.case-visual { position: relative; display: grid; min-height: 22rem; place-items: center; background: var(--paper-deep); border-right: 1px solid var(--line); overflow: hidden; } +.case-visual::before { position: absolute; width: 16rem; height: 9rem; border: 2px dashed var(--accent); border-radius: 50%; content: ""; transform: rotate(-18deg); opacity: .7; } +.case-visual::after { position: absolute; width: .8rem; height: .8rem; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 .45rem color-mix(in srgb, var(--accent), transparent 75%); content: ""; } +.case-initials { position: relative; z-index: 1; padding: .6rem .75rem; background: var(--paper-deep); font: 700 1.1rem var(--mono); } +.case-image { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; } +.case-body { padding: clamp(1.4rem, 4vw, 3rem); } +.case-topline { display: flex; flex-wrap: wrap; justify-content: space-between; gap: var(--s2); margin-bottom: var(--s4); } +.case-body h2 { margin-bottom: var(--s2); font-size: clamp(2rem, 4vw, 3.6rem); } +.case-summary { max-width: var(--reading); color: var(--muted); font-size: 1.05rem; } +.case-notes { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--s3); margin-block: var(--s5); } +.case-notes div { padding-top: var(--s3); border-top: 1px solid var(--line); } +.case-notes dt { margin-bottom: .35rem; color: var(--rust); font: .62rem var(--mono); text-transform: uppercase; } +.case-notes dd { margin: 0; color: var(--muted); font-size: .82rem; } +.case-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--s3); } +.loading-state { display: flex; align-items: center; gap: var(--s3); padding: var(--s5); border: 1px solid var(--line); } +.loading-mark { width: 1rem; height: 1rem; border: 2px solid var(--line); border-top-color: var(--rust); border-radius: 50%; animation: spin .8s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } +.writing-page { min-height: 55vh; padding-bottom: var(--s7); } +.coming-soon { display: grid; grid-template-columns: 1fr minmax(12rem, .35fr); gap: var(--s6); max-width: 62rem; padding: var(--s5); border: 1px dashed var(--rope); background: rgba(255, 250, 240, .5); } +.coming-soon p { color: var(--muted); } +.note-stack { display: grid; align-content: start; gap: .7rem; } +.note-stack span { display: block; padding: .7rem; border: 1px solid var(--line); background: var(--surface); font: .62rem var(--mono); transform: rotate(-1deg); } +.note-stack span:nth-child(2) { transform: rotate(1.5deg); } +@media (max-width: 56rem) { .page-intro { grid-template-columns: 1fr; gap: var(--s3); } .case-card { grid-template-columns: 1fr; } .case-visual { min-height: 12rem; border-right: 0; border-bottom: 1px solid var(--line); } .case-notes { grid-template-columns: 1fr; } } +@media (max-width: 38rem) { .page-intro { padding-block: var(--s6); } .case-actions { align-items: flex-start; flex-direction: column; } .coming-soon { grid-template-columns: 1fr; } } +@media (prefers-reduced-motion: reduce) { .loading-mark { animation: none; border-top-color: var(--line); } } + diff --git a/data/profile.json b/data/profile.json index 0ba4d27..2862745 100644 --- a/data/profile.json +++ b/data/profile.json @@ -1,11 +1,10 @@ { - "profile": { - "name": "Quinton", - "tagline": "CS student building projects and learning systems.", - "now": [ - "Building my portfolio site", - "Learning HTML, CSS, and JavaScript", - "Studying finance and investing basics" - ] - } -} \ No newline at end of file + "schemaVersion": 2, + "profile": { + "name": "Quinton", + "tagline": "Computer science student building practical software systems and technical experiments.", + "interests": ["Backend and full-stack development", "Data systems and optimization", "Geospatial computing", "Computer vision", "Practical product thinking"], + "now": ["Strengthening project validation", "Building clearer case studies", "Learning by shipping"] + } +} + diff --git a/data/projects.json b/data/projects.json index a2d65bc..9f66834 100644 --- a/data/projects.json +++ b/data/projects.json @@ -1,96 +1,90 @@ { + "schemaVersion": 2, "projects": [ { - "title": "Maple The Shepherd - Game", - "description": "Submission for the Brainless Mini Game Jam", - "status": "In Progress", - "tech": ["Godot", "GDScript", "Asprite", "Beepbox.co"], - "date": "2025-12-25", - "image": "../assets/projects/maple-the-sheep.png", - "link": "https://github.com/SimpleCaci/personal-portfolio/tree/main", - "problem": "Create a game revolving around the theme of Simpler Time", - "approach": "Designed a clean layout and built it with vanilla HTML/CSS/JS", - "result": "A retro golf-like shepherding game", - "learned": "Structuring front-end projects cleanly" + "id": "solar-system-missions", + "title": "Solar System Missions", + "shortDescription": "Explore spacecraft missions through NASA JPL Horizons data and generated trajectory plots.", + "category": "Geospatial systems", + "technologies": ["Python", "Flask", "JPL Horizons", "Matplotlib", "Three.js"], + "status": "Hackathon prototype under maintenance", + "repositoryUrl": "https://github.com/SimpleCaci/Turion-Hackathon-2025", + "year": "2025", + "problem": "Real mission data is powerful, but difficult to explore without specialized tools.", + "approach": "Query public ephemeris data, discover mission ranges, and turn vectors into a web-based mission story and trajectory image.", + "limitation": "The network-dependent workflow still needs current end-to-end API validation.", + "featured": true }, { - "title": "Portfolio Website", - "description": "My personal website to document projects and learning.", - "status": "In Progress", - "tech": ["HTML", "CSS", "JavaScript"], - "date": "2025-12-25", - "image": "../assets/projects/personal-portfolio.png", - "link": "https://github.com/SimpleCaci/personal-portfolio/tree/main", - "problem": "I needed to build a website for showcasing my skills", - "approach": "Designed a clean layout and built it with vanilla HTML/CSS/JS", - "result": "A responsive personal portfolio", - "learned": "Structuring front-end projects cleanly" + "id": "gesture-reactive-avatar", + "title": "Gesture-Reactive Avatar", + "shortDescription": "A local avatar that calibrates to a face and reacts to expressions in real time.", + "category": "Computer vision", + "technologies": ["Python", "OpenCV", "MediaPipe"], + "status": "Working prototype", + "repositoryUrl": "https://github.com/SimpleCaci/Gesture-Reactive-Avatar", + "year": "2025", + "problem": "A lightweight streaming avatar needs to feel responsive without uploading camera frames.", + "approach": "Measure facial landmarks locally, calibrate to the current user, smooth rapid changes, and select reaction images.", + "limitation": "Thresholds and camera behavior need testing across more faces, lighting, and devices.", + "featured": true }, { - "title": "Stop Time Blindness", - "description": "A lightweight task manager for daily productivity.", - "status": "Completed", - "tech": ["JavaScript", "PIP", "CSS", "cookies"], - "date": "2025-01-10", - "image": "../assets/projects/stop-time-blindness.png", - "link": "https://simplecaci.github.io/Stop-Time-Blindness/", - "problem": "I needed a simple way to track daily tasks", - "approach": "Used localStorage to persist tasks and JS for logic", - "result": "A fast offline task tracker that works in PIP for tasks", - "learned": "State management and DOM manipulation" + "id": "ambient-dashboard", + "title": "Ambient Dashboard", + "shortDescription": "A calm desktop workspace for priorities, progress, and focus sessions.", + "category": "Product systems", + "technologies": ["React", "TypeScript", "Tauri", "Rust"], + "status": "Active prototype", + "repositoryUrl": "https://github.com/SimpleCaci/ambient-dashboard", + "year": "2025", + "problem": "Daily state should be visible without becoming another noisy task manager.", + "approach": "Combine reusable progress controls, local persistence, theme palettes, and a focus timer inside a desktop shell.", + "limitation": "Weather, calendar integration, component tests, and native packaging remain future work.", + "featured": true }, { - "title": "Reading Crystal Ball", - "description": "Cutie Hack 2025 - Gen AI Winner", - "status": "Completed", - "tech": ["HTML","Python","Python-Flask", "JavaScript", "CSS", "genai"], - "date": "2025-11-15", - "image": "../assets/projects/reading-crystal-ball.png", - "link": "https://example.com", - "problem": "A computer vision project which help children read through a translating text to old english and speaking thorough a text to speech engine", - "approach": "Built a schedule-based planner UI", - "result": "Improved daily productivity", - "learned": "UI structure and time-based logic" - }, - { - "title": "Space Generation", - "description": "Turion Space Hack 2025 - Top 3 Winner", - "status": "Completed", - "tech": ["HTML","Python","Python-Flask", "API", "JavaScript", "CSS", "genai"], - "date": "2025-02-02", - "image": "../assets/projects/default.png", - "link": "https://github.com/SimpleCaci/Turion-Hackathon-2025", - "problem": "Help educate and be more educated about space events", - "approach": "Fetched data from the NASA API API", - "result": "Live learning of any past space event", - "learned": "Async fetch and GenAI systems" + "id": "handwriting-converter", + "title": "HandwritingConverter", + "shortDescription": "Turn photographed notes into reviewable text, confidence reports, and printable PDFs.", + "category": "Data pipeline", + "technologies": ["Python", "OpenCV", "Tesseract", "ReportLab", "pytest"], + "status": "Working beta", + "repositoryUrl": "https://github.com/SimpleCaci/HandwritingConverter", + "year": "2025", + "problem": "Handwriting is quick for thinking, but paper notes are difficult to search, revise, and print.", + "approach": "Preprocess images locally, run OCR, report page confidence, and create text, HTML review, and PDF outputs.", + "limitation": "Recognition quality still depends on handwriting, lighting, camera angle, and Tesseract language data.", + "featured": true }, { - "title": "Handwriting Converter", - "description": "Converts handwritten text into digital text.", - "status": "Prototype", - "tech": ["Python", "OCR", "OpenCV"], - "date": "2025-03-05", - "image": "../assets/projects/default.png", - "link": "https://example.com", - "problem": "Needed a way to digitize handwritten notes", - "approach": "Used OCR libraries to process images", - "result": "Basic handwriting recognition tool", - "learned": "Image processing and OCR pipelines" + "id": "voxnav", + "title": "VoxNav", + "shortDescription": "A Windows voice-control assistant with explicit commands and a safe preview mode.", + "category": "Human input", + "technologies": ["Python", "SpeechRecognition", "PyAutoGUI", "unittest"], + "status": "Functional Windows prototype", + "repositoryUrl": "https://github.com/SimpleCaci/VoxNav", + "year": "2025", + "problem": "Hands-free desktop navigation needs predictable commands and visible safety boundaries.", + "approach": "Parse normalized transcripts into allowlisted actions, trace each decision, and provide a side-effect-free text preview.", + "limitation": "Speech currently uses an external service; real desktop actions still need intentional manual testing.", + "featured": true }, { - "title": "Fitness Tracker", - "description": "Logs workouts and tracks progress.", - "status": "Planned", - "tech": ["JavaScript", "Chart.js"], - "date": "2025-03-20", - "image": "../assets/projects/default.png", - "link": "https://example.com", - "problem": "Wanted to visualize workout progress", - "approach": "Designed charts for reps and sets", - "result": "Clear visualization of fitness data", - "learned": "Data visualization basics" + "id": "yoga-pose-match", + "title": "Yoga Pose Match", + "shortDescription": "A browser-based pose game with live landmark feedback and joint-specific coaching.", + "category": "Browser vision", + "technologies": ["JavaScript", "MediaPipe", "Canvas", "FastAPI", "pandas"], + "status": "Functional hackathon prototype", + "repositoryUrl": "https://github.com/SimpleCaci/Rose-Hackathon-2026", + "year": "2026", + "problem": "Pose-estimation data needs friendly feedback before it feels like a movement game.", + "approach": "Compare browser camera landmarks to data-derived angle targets and name the joint furthest from its target.", + "limitation": "Camera behavior and pose accuracy still require cross-device testing.", + "featured": true } ] } - + diff --git a/data/writings.json b/data/writings.json index cc1fade..68e00bb 100644 --- a/data/writings.json +++ b/data/writings.json @@ -1,9 +1,5 @@ { - "writing": [ - { - "title": "Why I Started This Site", - "date": "2024-12-25", - "summary": "Documenting my learning journey and building in public." - } - ] -} \ No newline at end of file + "schemaVersion": 2, + "writing": [] +} + diff --git a/index.html b/index.html index e82b0db..28aed24 100644 --- a/index.html +++ b/index.html @@ -1,97 +1,49 @@ - - - - Quinton — Software Projects - - - - - - - - - + + + + + + + + + + Quinton - Applied systems and software + + + - -
- -
- -
- -
- cursor image -
- -
- - -
- Top Bar Image -

/QUINTON

-
- - -
-

About Me

-

This is a brief introduction about myself.

-
- - -
-

Current Projects

-

This is my list of projects.

-
- -
-
- - - -
- -
- -
- - - - - - - + + +
+
+

CS + business / California

Software for the parts of life that do not arrive as clean data.

I am Quinton. I learn by building systems around movement, speech, handwriting, mission data, and everyday routines - then documenting where they work and where they still need proof.

+ +
+
+
+

Selected systems / 2025-2026

Each starts with a messy input and asks the same question: what would make this genuinely useful?

+ +
+

Working prototype

Gesture-Reactive Avatar

A local webcam pipeline that calibrates to a face, smooths expression changes, and maps them to a playful avatar.

  • OpenCV
  • MediaPipe
  • Python
Repository ↗
+

Active prototype

Ambient Dashboard

A calm desktop workspace for priorities and focus sessions - useful state without another noisy task app.

  • React
  • TypeScript
  • Tauri
  • Rust
Repository ↗
+

Working beta

HandwritingConverter

A local pipeline that turns photographed notes into reviewable text, confidence reports, and printable multipage PDFs.

  • OpenCV
  • Tesseract
  • ReportLab
  • pytest
Repository ↗
+

Functional prototype

VoxNav

A Windows voice-control assistant with explicit command parsing, safe text preview, and visible decision tracing.

  • Python
  • Speech
  • PyAutoGUI
  • Tests
Repository ↗
+

See all selected projects and technical notes ->

+
+

Working method / 04 steps

Build the useful version.

  1. 01

    Start with friction

    Find a real workflow, not a feature looking for a reason to exist.

  2. 02

    Map the system

    Make inputs, transformations, constraints, and failure states visible.

  3. 03

    Test the risky part

    Separate deterministic logic from cameras, APIs, speech, and unstable edges.

  4. 04

    Tell the truth

    Call a prototype a prototype. Document what works and what still needs proof.

+

About / Quinton

Technical curiosity, with a business question attached.

I am a computer science student interested in backend and full-stack development, data systems, optimization, geospatial computing, and computer vision.

Business changes how I look at software: who is it for, what does it replace, where does it fail, and is the result useful enough to keep?

Right now: strengthening project validation, building clearer case studies, and learning by shipping.

Field assistant
+

Open channel / 01

Have an interesting problem?

I am always interested in thoughtful projects, useful systems, and learning from people building them.

+
+ + + diff --git a/js/contacts.js b/js/contacts.js deleted file mode 100644 index ed5ee49..0000000 --- a/js/contacts.js +++ /dev/null @@ -1,189 +0,0 @@ -/* ========================================================= - ADHD-CODED JS (tiny but powerful) - - Progress bar - - Character counter - - “Quick chips” that auto-fill message - - Autosave draft (localStorage) - - Haptic feedback on mobile (vibrate) - Works with your existing .contacts + .contact-form markup - ========================================================= */ - -const form = document.querySelector(".contact-form"); -if (!form) throw new Error("Missing .contact-form"); - -const fields = { - name: form.querySelector("#name"), - email: form.querySelector("#email"), - reason: form.querySelector("#reason"), - priority: form.querySelector("#priority"), - timeline: form.querySelector("#timeline"), - links: form.querySelector("#links"), - message: form.querySelector("#message"), -}; -const submitBtn = form.querySelector(".contact-submit"); - -/* ---------- Inject progress + chips + counter (no HTML changes needed) ---------- */ -const progress = document.createElement("div"); -progress.className = "contact-progress"; -progress.innerHTML = ``; -form.prepend(progress); - -const chipRow = document.createElement("div"); -chipRow.className = "chip-row"; -chipRow.innerHTML = ` - - - - - -`; -form.insertBefore(chipRow, form.querySelector(".form-row")); // puts chips near top - -const counterRow = document.createElement("div"); -counterRow.className = "char-row"; -counterRow.innerHTML = ` -
Tip: 2–5 sentences is perfect.
-
0/600
-`; -fields.message.parentElement.appendChild(counterRow); - -const counterNum = counterRow.querySelector(".char-counter b"); -const progressBar = progress.querySelector("span"); - -/* ---------- Progress calculation ---------- */ -const requiredEls = [fields.email, fields.reason, fields.message]; -const allTrackEls = [fields.name, fields.email, fields.reason, fields.priority, fields.timeline, fields.links, fields.message]; - -function clamp(n, a, b){ return Math.max(a, Math.min(b, n)); } - -function updateProgress() { - // required completion counts more - let score = 0; - let total = 0; - - requiredEls.forEach(el => { - total += 2; - if (el && el.value && el.checkValidity()) score += 2; - }); - - allTrackEls.forEach(el => { - if (!el || requiredEls.includes(el)) return; - total += 1; - if (el.value.trim().length > 0) score += 1; - }); - - const pct = clamp(Math.round((score / total) * 100), 0, 100); - progressBar.style.width = pct + "%"; -} - -/* ---------- Character counter ---------- */ -const MAX = 600; -fields.message.setAttribute("maxlength", String(MAX)); - -function updateCounter() { - const len = fields.message.value.length; - counterNum.textContent = String(len); - // gentle warning near limit - if (len > MAX - 80) { - fields.message.style.borderColor = "rgba(255,193,7,0.55)"; - } else { - fields.message.style.borderColor = ""; - } -} - -/* ---------- Field “done” pop + haptics ---------- */ -function popDone(el) { - el.classList.remove("pt-done"); - void el.offsetWidth; // restart animation - el.classList.add("pt-done"); - if (navigator.vibrate) navigator.vibrate(12); -} - -/* ---------- Chips fill message ---------- */ -chipRow.addEventListener("click", (e) => { - const btn = e.target.closest(".chip"); - if (!btn) return; - const add = btn.dataset.fill || ""; - const msg = fields.message; - msg.value = (msg.value ? msg.value + "\n" : "") + add; - msg.focus(); - updateCounter(); - updateProgress(); - popDone(msg); -}); - -/* ---------- Autosave draft ---------- */ -const KEY = "contact_draft_v1"; -function saveDraft() { - const payload = {}; - Object.keys(fields).forEach(k => payload[k] = (fields[k]?.value ?? "")); - localStorage.setItem(KEY, JSON.stringify(payload)); -} -function loadDraft() { - const raw = localStorage.getItem(KEY); - if (!raw) return; - try { - const payload = JSON.parse(raw); - Object.keys(fields).forEach(k => { - if (fields[k] && typeof payload[k] === "string" && payload[k].length) fields[k].value = payload[k]; - }); - } catch {} -} -loadDraft(); - -/* ---------- Wire events ---------- */ -allTrackEls.forEach(el => { - if (!el) return; - el.addEventListener("input", () => { - updateProgress(); - if (el === fields.message) updateCounter(); - saveDraft(); - }); - el.addEventListener("change", () => { - updateProgress(); - saveDraft(); - if (el.value && el.checkValidity?.()) popDone(el); - }); -}); - -/* Message counter init */ -updateCounter(); -updateProgress(); - -/* ---------- Cow tap = dopamine (tiny haptic + micro compliment) ---------- */ -const cow = document.querySelector(".work-in-progress-image"); -if (cow) { - cow.addEventListener("click", () => { - if (navigator.vibrate) navigator.vibrate([10, 20, 10]); - // playful, minimal, not wordy: - const msgs = ["moo.", "cow buff applied.", "message power +10."]; - cow.alt = msgs[Math.floor(Math.random() * msgs.length)]; - setTimeout(() => (cow.alt = "Work in Progress"), 800); - }); -} - -/* ---------- Submit: validate + feedback ---------- */ -form.addEventListener("submit", (e) => { - // front-end validation feedback - const bad = requiredEls.find(el => !el.checkValidity()); - if (bad) { - e.preventDefault(); - form.classList.remove("sent"); - form.classList.add("shake"); - setTimeout(() => form.classList.remove("shake"), 450); - bad.focus(); - if (navigator.vibrate) navigator.vibrate([25, 30, 25]); - return; - } - - // show “sending” state (bucket will handle the actual post) - submitBtn.classList.add("sending"); - if (navigator.vibrate) navigator.vibrate(18); - - // if you use an external bucket that redirects, you won't see this, - // but it still looks nice in cases where it stays on page: - setTimeout(() => { - form.classList.add("sent"); - localStorage.removeItem(KEY); - }, 600); -}); diff --git a/js/cursor.js b/js/cursor.js deleted file mode 100644 index 3605913..0000000 --- a/js/cursor.js +++ /dev/null @@ -1,15 +0,0 @@ -const cursor = document.getElementById("cursor"); - -document.addEventListener("mousemove", (e) => { - cursor.style.left = e.clientX + "px"; - cursor.style.top = e.clientY + "px"; -}); - -document.querySelectorAll("a").forEach(el => { - el.addEventListener("mouseenter", () => { - cursor.style.transform = "translate(-50%, -50%) scale(1.2)"; - }); - el.addEventListener("mouseleave", () => { - cursor.style.transform = "translate(-50%, -50%) scale(1)"; - }); -}); \ No newline at end of file diff --git a/js/hero.js b/js/hero.js deleted file mode 100644 index 5c79e72..0000000 --- a/js/hero.js +++ /dev/null @@ -1,15 +0,0 @@ -const hero = document.querySelector(".top-bar-section"); - -hero.addEventListener("mousemove", (e) => { - const r = hero.getBoundingClientRect(); - const x = (e.clientX - r.left) / r.width - 0.5; - const y = (e.clientY - r.top) / r.height - 0.5; - - hero.style.setProperty("--px", (x * 14).toFixed(2) + "px"); - hero.style.setProperty("--py", (y * 10).toFixed(2) + "px"); -}); - -hero.addEventListener("mouseleave", () => { - hero.style.setProperty("--px", "0px"); - hero.style.setProperty("--py", "0px"); -}); diff --git a/js/main.js b/js/main.js index 2483309..6e49e4d 100644 --- a/js/main.js +++ b/js/main.js @@ -1,39 +1,8 @@ -//global variables -let projects; - -//to grab and parse the JSON file -async function grabData(dataName) { - const requestURL = "../data/"+dataName+".json"; - const request = new Request(requestURL); - - const response = await fetch(request); - const data = await response.json(); - - console.log("Data loaded:", data) - - return data; -} - -//to populate projects -function populateProject(obj) { - const section = document.querySelector(".current-items-section"); - const myH1 = document.createElement("h1"); - myH1.textContent = obj.title; - section.appendChild(myH1); - - const myPara = document.createElement("p"); - myPara.textContent = `Disc: ${obj.description} // Status: ${obj.status}`; - section.appendChild(myPara); -} - - - -// async usage -(async function () { - const data = await grabData("projects"); - projects = data.projects; - for (const project of projects){ // javascript uses "of" to grab the elements and "for" to grab indices - populateProject(project); - } -})(); - +document.querySelectorAll("[data-current-year]").forEach((element) => { + element.textContent = String(new Date().getFullYear()); +}); + +document.querySelectorAll(".mobile-nav a").forEach((link) => { + link.addEventListener("click", () => link.closest("details")?.removeAttribute("open")); +}); + diff --git a/js/projects.js b/js/projects.js index d043c1f..3513529 100644 --- a/js/projects.js +++ b/js/projects.js @@ -1,175 +1,117 @@ -// ======= CONFIG: map tech name -> icon path (auto-fallback included) ======= -const TECH_ICON_BASE = "../assets/icons"; // adjust to your folder (you wrote ./asset/icon) -const TECH_ICON_EXT = "png"; // png/svg etc +const list = document.querySelector("[data-project-list]"); +const loading = document.querySelector("[data-project-loading]"); +const errorState = document.querySelector("[data-project-error]"); -function slugTech(t) { - return String(t || "") - .trim() - .toLowerCase() - .replace(/\+/g, "plus") - .replace(/\#/g, "sharp") - .replace(/[^\w]+/g, "-") - .replace(/^-+|-+$/g, ""); +function safeText(value, fallback) { + return typeof value === "string" && value.trim() ? value.trim() : fallback; } -function techIconPath(techName) { - const slug = slugTech(techName); - return `${TECH_ICON_BASE}/${slug}.${TECH_ICON_EXT}`; -} - -function buildTechIcons(techArr = []) { - const wrap = document.createElement("div"); - wrap.className = "project-tech-icons"; - - for (const t of techArr) { - const chip = document.createElement("span"); - chip.className = "tech-chip"; - - const img = document.createElement("img"); - img.className = "tech-icon"; - img.alt = t; - img.loading = "lazy"; - img.decoding = "async"; - - console.log("TECH:", t, "=>", techIconPath(t)); - - img.src = techIconPath(t); - - // fallback: if missing icon, show text chip - img.onerror = () => { - img.remove(); - chip.textContent = t; - chip.classList.add("tech-chip--text"); - }; - - chip.title = t; - chip.dataset.label = t; - - chip.appendChild(img); - wrap.appendChild(chip); +function validUrl(value) { + if (typeof value !== "string") return null; + try { + const url = new URL(value); + return url.protocol === "https:" ? url.href : null; + } catch { + return null; } - - return wrap; } -// ======= global ======= -let projects; - -// fetch JSON -async function grabData(dataName) { - const requestURL = `../data/${dataName}.json`; - const response = await fetch(requestURL); - return await response.json(); -} - -function populateProject(project) { - const section = document.querySelector(".current-items-section"); - - const card = document.createElement("article"); - card.className = "project-card"; - card.dataset.date = project.date; - - /* ---------------- HEADER ---------------- */ - const header = document.createElement("div"); - header.className = "project-header"; - - const title = document.createElement("h2"); - title.className = "project-title"; - title.textContent = project.title; - - const meta = document.createElement("p"); - meta.className = "project-meta"; - meta.textContent = `${project.status} • ${project.date}`; - - header.append(title, meta); - - /* ---------------- IMAGE ---------------- */ - if (project.image) { - const imgWrap = document.createElement("div"); - imgWrap.className = "project-image-wrap"; - - const img = document.createElement("img"); - img.src = project.image; - img.alt = project.title; - img.className = "project-image"; - img.loading = "lazy"; - img.decoding = "async"; - - imgWrap.appendChild(img); - card.appendChild(imgWrap); +function projectCard(project, index) { + const article = document.createElement("article"); + article.className = "case-card"; + + const visual = document.createElement("div"); + visual.className = "case-visual"; + const initials = document.createElement("span"); + initials.className = "case-initials"; + initials.setAttribute("aria-hidden", "true"); + initials.textContent = safeText(project.title, "Project").split(/\s+/).slice(0, 2).map((word) => word[0]).join("").toUpperCase(); + visual.append(initials); + + if (typeof project.image === "string" && project.image.trim()) { + const image = document.createElement("img"); + image.className = "case-image"; + image.src = project.image; + image.alt = safeText(project.imageAlt, ""); + image.loading = "lazy"; + image.addEventListener("error", () => image.remove(), { once: true }); + visual.append(image); } - /* ---------------- BODY ---------------- */ const body = document.createElement("div"); - body.className = "project-body"; - - // tech icons row (compact) - body.appendChild(buildTechIcons(project.tech || [])); - - const desc = document.createElement("p"); - desc.className = "project-desc"; - desc.textContent = project.description; - - const details = document.createElement("div"); - details.className = "project-details"; - - const problem = document.createElement("p"); - problem.innerHTML = `Problem: ${project.problem}`; - - const approach = document.createElement("p"); - approach.innerHTML = `Approach: ${project.approach}`; - - const result = document.createElement("p"); - result.innerHTML = `Result: ${project.result}`; - - const learned = document.createElement("p"); - learned.innerHTML = `Learned: ${project.learned}`; - - details.append(problem, approach, result, learned); - body.append(desc, details); - - /* ---------------- FOOTER ---------------- */ - const footer = document.createElement("div"); - footer.className = "project-footer"; + body.className = "case-body"; + const top = document.createElement("div"); + top.className = "case-topline"; + const category = document.createElement("span"); + category.className = "eyebrow"; + category.textContent = `${String(index + 1).padStart(2, "0")} / ${safeText(project.category, "Experiment")}`; + const status = document.createElement("span"); + status.className = "status-label"; + status.textContent = safeText(project.status, "Status not documented"); + top.append(category, status); - const link = document.createElement("a"); - link.href = project.link; - link.textContent = "View Project →"; - link.target = "_blank"; - link.rel = "noopener noreferrer"; - link.className = "project-link"; - - footer.appendChild(link); - - /* ---------------- ASSEMBLE ---------------- */ - card.append(header, body, footer); - section.appendChild(card); + const title = document.createElement("h2"); + title.textContent = safeText(project.title, "Untitled project"); + const summary = document.createElement("p"); + summary.className = "case-summary"; + summary.textContent = safeText(project.shortDescription, "Project notes are still being written."); + + const notes = document.createElement("dl"); + notes.className = "case-notes"; + [["Problem", project.problem], ["Approach", project.approach], ["Current limitation", project.limitation]].forEach(([label, value]) => { + const group = document.createElement("div"); + const term = document.createElement("dt"); + term.textContent = label; + const detail = document.createElement("dd"); + detail.textContent = safeText(value, "Not documented yet."); + group.append(term, detail); + notes.append(group); + }); + + const actions = document.createElement("div"); + actions.className = "case-actions"; + const tags = document.createElement("ul"); + tags.className = "tag-list"; + (Array.isArray(project.technologies) ? project.technologies : []).slice(0, 6).forEach((technology) => { + if (typeof technology !== "string" || !technology.trim()) return; + const item = document.createElement("li"); + item.textContent = technology.trim(); + tags.append(item); + }); + actions.append(tags); + const repositoryUrl = validUrl(project.repositoryUrl); + if (repositoryUrl) { + const link = document.createElement("a"); + link.className = "text-link"; + link.href = repositoryUrl; + link.target = "_blank"; + link.rel = "noopener noreferrer"; + link.textContent = "View repository ↗"; + link.setAttribute("aria-label", `View ${title.textContent} repository on GitHub`); + actions.append(link); + } + body.append(top, title, summary, notes, actions); + article.append(visual, body); + return article; } -// scrollspy -function setupDateScrollSpy() { - const activeDateEl = document.getElementById("activeDate"); - const cards = document.querySelectorAll(".project-card"); - if (!activeDateEl || !cards.length) return; - - activeDateEl.textContent = cards[0].dataset.date || "—"; - - const observer = new IntersectionObserver((entries) => { - const visible = entries - .filter(e => e.isIntersecting) - .sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0]; - - if (visible) activeDateEl.textContent = visible.target.dataset.date || "—"; - }, { threshold: [0.25, 0.5, 0.75], rootMargin: "-30% 0px -50% 0px" }); - - cards.forEach(card => observer.observe(card)); +async function loadProjects() { + if (!list) return; + try { + const response = await fetch("../data/projects.json", { headers: { Accept: "application/json" } }); + if (!response.ok) throw new Error(`Project data returned ${response.status}`); + const data = await response.json(); + if (!data || !Array.isArray(data.projects) || !data.projects.length) throw new Error("Project data is empty or malformed"); + const fragment = document.createDocumentFragment(); + data.projects.forEach((project, index) => fragment.append(projectCard(project, index))); + list.replaceChildren(fragment); + loading?.remove(); + } catch (error) { + console.error("Unable to load project data", error); + loading?.remove(); + errorState?.removeAttribute("hidden"); + } } -// run after DOM exists -document.addEventListener("DOMContentLoaded", async () => { - const data = await grabData("projects"); - projects = data.projects; - - for (const project of projects) populateProject(project); - setupDateScrollSpy(); -}); +loadProjects(); + diff --git a/js/rope.js b/js/rope.js deleted file mode 100644 index d99a7bc..0000000 --- a/js/rope.js +++ /dev/null @@ -1,82 +0,0 @@ -// ===== Rope Pull → Transition → Navigate ===== -const rope = document.getElementById("rope"); - -// --- Config --- -const CFG = { - pullThreshold: 95, // trigger when pulled past this - follow: 0.18, // smoothing while dragging - ret: 0.10, // smoothing while returning - swayAmp: 2.2, // degrees - swaySpeed: 0.018, // sway frequency - maxDrag: 220, // max visual pull distance (clamp) - navigateTo: "../index.html", - navigateAt: 0.5 // 0.5 = at full cover (t/2 in your transition) -}; - -// --- State --- -const S = { - dragging: false, - transitioned: false, - startY: 0, - targetY: 0, - currentY: 0, - t: 0, - rotate: 0 -}; - -// --- Helpers --- -const clamp = (v, min, max) => Math.min(max, Math.max(min, v)); - -function setDragging(on, e) { - S.dragging = on; - if (on && e) S.startY = e.clientY - S.currentY; // preserves continuity -} - -function computeTargetY(e) { - const raw = e.clientY - S.startY; - S.targetY = clamp(raw, 0, CFG.maxDrag); -} - -function updateSway() { - S.t += 1; - S.rotate = S.dragging ? 0 : Math.sin(S.t * CFG.swaySpeed) * CFG.swayAmp; -} - -function updatePosition() { - const goal = S.dragging ? S.targetY : 0; - const k = S.dragging ? CFG.follow : CFG.ret; - S.currentY += (goal - S.currentY) * k; -} - -function render() { - rope.style.transform = `translate(-50%, ${S.currentY-100}px) rotate(${S.rotate}deg)`; -} - -function shouldTriggerTransition() { - return !S.transitioned && S.targetY >= CFG.pullThreshold; -} - -function runTransitionAndNavigate() { - S.transitioned = true; - const dur = barsZoomTransition(); // must exist globally - setTimeout(() => window.location.href = CFG.navigateTo, dur * CFG.navigateAt); -} - -// --- Events --- -rope.addEventListener("mousedown", (e) => setDragging(true, e)); - -document.addEventListener("mousemove", (e) => { - if (!S.dragging) return; - computeTargetY(e); - if (shouldTriggerTransition()) runTransitionAndNavigate(); -}); - -document.addEventListener("mouseup", () => setDragging(false)); - -// --- Animation Loop --- -(function tick() { - updateSway(); - updatePosition(); - render(); - requestAnimationFrame(tick); -})(); diff --git a/js/titleAnimation.js b/js/titleAnimation.js deleted file mode 100644 index df029fa..0000000 --- a/js/titleAnimation.js +++ /dev/null @@ -1,175 +0,0 @@ -// Enhanced /QUINTON title: crisp slash blink + outline/drop-shadow + multi-animations -// Works with:

/QUINTON

- -const title = document.querySelector(".page-title"); -if (!title) throw new Error("Missing .page-title"); - -const raw = title.textContent.trim(); -const hasSlash = raw.startsWith("/"); -const word = hasSlash ? raw.slice(1) : raw; - -// Build spans -title.innerHTML = - (hasSlash ? `/` : "") + - word.split("").map((ch, i) => `${ch}`).join(""); - -// Inject CSS (self-contained) -const style = document.createElement("style"); -style.textContent = ` - .page-title{ - display:inline-flex; - align-items:baseline; - gap:0.06em; - user-select:none; - position:relative; - } - - /* Base readability: outline + shadow so blink is visible on clouds */ - .page-title .pt-slash, - .page-title .pt-char{ - display:inline-block; - -webkit-text-stroke: 2px rgba(10, 18, 40, 0.18); - text-shadow: - 0 2px 0 rgba(10,18,40,0.28), - 0 10px 24px rgba(0,0,0,0.22); - transition: - color 220ms ease, - transform 220ms ease, - text-shadow 220ms ease, - filter 220ms ease, - opacity 180ms ease; - will-change: transform, color, text-shadow, filter, opacity; - } - - .page-title .pt-slash{ - margin-right:0.02em; - filter: drop-shadow(0 0 10px rgba(255,255,255,0.22)); - } - - /* Ambient “sparkle” for the whole word (very subtle) */ - @keyframes titleShimmer { - 0% { filter: drop-shadow(0 0 0 rgba(255,255,255,0)); } - 50% { filter: drop-shadow(0 0 14px rgba(255,255,255,0.18)); } - 100% { filter: drop-shadow(0 0 0 rgba(255,255,255,0)); } - } - .page-title{ animation: titleShimmer 3.2s ease-in-out infinite; } - - /* “Attention” bounce on hover */ - @keyframes microBounce{ - 0% { transform: translateY(0); } - 35%{ transform: translateY(-2px); } - 70%{ transform: translateY(0); } - 100%{ transform: translateY(0); } - } - .page-title:hover .pt-char, - .page-title:hover .pt-slash{ - animation: microBounce 700ms ease-out 1; - } - - /* Reduce motion */ - @media (prefers-reduced-motion: reduce){ - .page-title{ animation:none; } - .page-title:hover .pt-char, - .page-title:hover .pt-slash{ animation:none; } - } -`; -document.head.appendChild(style); - -const slashEl = title.querySelector(".pt-slash"); -const chars = [...title.querySelectorAll(".pt-char")]; - -// ---------- Slash blink: visible + “terminal glow” ---------- -if (slashEl) { - let on = true; - setInterval(() => { - on = !on; - slashEl.style.opacity = on ? "1" : "0.08"; // not fully gone so you still see the outline - slashEl.style.textShadow = on - ? "0 2px 0 rgba(10,18,40,0.28), 0 10px 24px rgba(0,0,0,0.22), 0 0 18px rgba(255,255,255,0.34)" - : "0 2px 0 rgba(10,18,40,0.32), 0 10px 24px rgba(0,0,0,0.22), 0 0 8px rgba(255,255,255,0.12)"; - }, 520); - - // Tiny “spark” tick to make it feel alive - let spark = false; - setInterval(() => { - spark = !spark; - slashEl.style.filter = spark - ? "drop-shadow(0 0 14px rgba(255,255,255,0.28))" - : "drop-shadow(0 0 10px rgba(255,255,255,0.18))"; - }, 1100); -} - -// ---------- Per-character wave: highlight travels across letters ---------- -const base = { r: 170, g: 178, b: 190 }; // smoky gray-blue -const hi = { r: 255, g: 255, b: 255 }; // bright highlight -const accent = { r: 140, g: 190, b: 255 }; // soft sky accent - -function mix(a, b, amt) { - const r = Math.round(a.r + (b.r - a.r) * amt); - const g = Math.round(a.g + (b.g - a.g) * amt); - const bb = Math.round(a.b + (b.b - a.b) * amt); - return `rgb(${r},${g},${bb})`; -} - -function falloff(dist) { - return Math.max(0, Math.exp(-dist * 0.85)); // smooth decay -} - -let t = 0; -let hoverSlow = false; - -title.addEventListener("mouseenter", () => (hoverSlow = true)); -title.addEventListener("mouseleave", () => (hoverSlow = false)); - -function animate() { - const n = chars.length; - if (!n) return; - - // focus slides across; on hover it slows down for readability - const speed = hoverSlow ? 0.030 : 0.060; - const focus = (t * speed) % n; - - chars.forEach((el, i) => { - const d = Math.abs(i - focus); - const f = falloff(d); - - // color blend: base -> accent -> white as it gets closer - const mid = mix(base, accent, Math.min(1, f * 0.85)); - const col = mix({ r: parseInt(mid.slice(4)), g: 0, b: 0 }, hi, 0); // placeholder to avoid parsing - // Use two-step without parsing: just compute directly - const color = f > 0.55 ? mix(accent, hi, (f - 0.55) / 0.45) : mix(base, accent, f / 0.55); - - el.style.color = color; - - // lift and “bounce” near the focus - const lift = (f > 0.75) ? -3 : (f > 0.45 ? -1.5 : 0); - el.style.transform = `translateY(${lift}px)`; - - // glow near focus + stronger outline for legibility - el.style.textShadow = (f > 0.55) - ? `0 2px 0 rgba(10,18,40,0.30), - 0 10px 24px rgba(0,0,0,0.24), - 0 0 ${Math.round(18 * f)}px rgba(255,255,255,${0.26 * f})` - : `0 2px 0 rgba(10,18,40,0.28), - 0 10px 24px rgba(0,0,0,0.22)`; - }); - - // occasional “sparkle ping” that hits a random letter (subtle) - if (t % 240 === 0 && n > 2) { - const j = Math.floor(Math.random() * n); - const el = chars[j]; - el.style.filter = "drop-shadow(0 0 14px rgba(255,255,255,0.22))"; - setTimeout(() => (el.style.filter = "none"), 220); - } - - t += 1; - requestAnimationFrame(animate); -} -requestAnimationFrame(animate); - -// ---------- Optional: pause work when tab hidden (saves CPU) ---------- -document.addEventListener("visibilitychange", () => { - if (document.hidden) { - chars.forEach(el => { el.style.transform = "translateY(0)"; el.style.filter = "none"; }); - } -}); diff --git a/js/transition.js b/js/transition.js deleted file mode 100644 index fb2c6de..0000000 --- a/js/transition.js +++ /dev/null @@ -1,96 +0,0 @@ -// Fix: navigate AFTER the cover animation finishes (i.e., when all bars are fully scaleX(1)) - -function barsZoomTransition({ - bars = 8, - inMs = 600, - coverHoldMs = 400, - outMs = 600, - hideAfterMs = 80 -} = {}) { - const wipe = document.getElementById("wipe"); - if (!wipe) throw new Error("Missing #wipe element"); - - buildBars(wipe, bars); - const barEls = [...wipe.children]; - - wipe.style.opacity = "1"; - - const stIn = inMs / (bars + 6); - const stOut = outMs / (bars + 6); - - // cover - barEls.forEach((b, i) => { - b.animate( - [{ transform: "scaleX(0)" }, { transform: "scaleX(1)" }], - { duration: inMs, delay: i * stIn, easing: "cubic-bezier(.2,.9,.2,1)", fill: "forwards" } - ); - }); - - // IMPORTANT: cover is only guaranteed complete after the last bar finishes - const coverDoneAt = inMs + (bars - 1) * stIn; - - // reveal - const startRevealAt = coverDoneAt + coverHoldMs; - barEls.forEach((b, i) => { - b.animate( - [{ transform: "scaleX(1)" }, { transform: "scaleX(0)" }], - { duration: outMs, delay: startRevealAt + i * stOut, easing: "cubic-bezier(.4,0,.2,1)", fill: "forwards" } - ); - }); - - const total = startRevealAt + outMs + (bars - 1) * stOut + hideAfterMs; - setTimeout(() => (wipe.style.opacity = "0"), total); - - return { total, coverDoneAt, startRevealAt }; -} - -function buildBars(wipe, bars) { - wipe.style.gridTemplateRows = `repeat(${bars}, 1fr)`; - wipe.innerHTML = ""; - for (let i = 0; i < bars; i++) { - const bar = document.createElement("div"); - bar.className = "bar"; - bar.style.transformOrigin = i % 2 === 0 ? "left center" : "right center"; - wipe.appendChild(bar); - } -} - -// Navigate only when fully covered (no chance to show the next page early): -// const tr = barsZoomTransition({ bars: 8, coverHoldMs: 300 }); -// setTimeout(() => (window.location.href = "../index.html"), tr.coverDoneAt + 50); - - - -// REVERSE ANIMATIONS (for load ins) -function playReverseTransition({ bars = 10, outMs = 800 } = {}) { - const wipe = document.getElementById("wipe"); - wipe.style.opacity = "0"; // keep hidden while building - wipe.style.gridTemplateRows = `repeat(${bars}, 1fr)`; - wipe.innerHTML = ""; - - for (let i = 0; i < bars; i++) { - const bar = document.createElement("div"); - bar.className = "bar"; - bar.style.transformOrigin = i % 2 === 0 ? "left center" : "right center"; - bar.style.transform = "scaleX(1)"; // start covered for reverse - wipe.appendChild(bar); - } - - // show only after bars exist - wipe.style.opacity = "1"; - - const barEls = [...wipe.children]; - const stagger = outMs / (bars + 6); - - barEls.forEach((b, i) => { - b.animate( - [{ transform: "scaleX(1)" }, { transform: "scaleX(0)" }], - { duration: outMs, delay: i * stagger, easing: "cubic-bezier(.4,0,.2,1)", fill: "forwards" } - ); - }); - - const total = outMs + (bars - 1) * stagger + 50; - setTimeout(() => (wipe.style.opacity = "0"), total); -} - -window.addEventListener("load", () => playReverseTransition()); \ No newline at end of file diff --git a/projects/index.html b/projects/index.html index fbab136..f52922f 100644 --- a/projects/index.html +++ b/projects/index.html @@ -1,59 +1,22 @@ - - - - Projects — Quinton - - - - + + + + Projects - Quinton -
- -
- -
-
-
- -
- Top Bar Image -

/PROJECTS

-
- - - - -
-

Current Projects

-

This is my list of projects.

-
-
-
- - - - -
- - -
- -
- - - - - - - - - \ No newline at end of file + + +
+

Project index / Selected work

Systems, tools, and experiments.

These are the projects that best show how I think: start with a real input, make the system visible, and document the parts that still need work.

No live demo is implied. Each link goes to a verified repository.

+
+ +
+ + + + diff --git a/robots.txt b/robots.txt new file mode 100644 index 0000000..191ed48 --- /dev/null +++ b/robots.txt @@ -0,0 +1,5 @@ +User-agent: * +Allow: / + +Sitemap: https://simplecaci.github.io/personal-portfolio/sitemap.xml + diff --git a/scripts/validate_site.py b/scripts/validate_site.py new file mode 100644 index 0000000..c770c5f --- /dev/null +++ b/scripts/validate_site.py @@ -0,0 +1,135 @@ +"""Lightweight, dependency-free checks for the static portfolio.""" + +from __future__ import annotations + +import json +import re +import sys +from html.parser import HTMLParser +from pathlib import Path +from urllib.parse import urlparse + +ROOT = Path(__file__).resolve().parents[1] +HTML_FILES = [ROOT / "index.html", ROOT / "projects/index.html", ROOT / "writings/index.html", ROOT / "contacts/index.html"] +REQUIRED_PROJECT_FIELDS = {"id", "title", "shortDescription", "category", "technologies", "status", "repositoryUrl", "year", "problem", "approach", "limitation"} +BAD_TEXT = ("example.com", "This is a brief", "placeholder copy", "â€", "â†", "·") + + +class PageParser(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.ids: set[str] = set() + self.links: list[dict[str, str | None]] = [] + self.images: list[dict[str, str | None]] = [] + self.titles = 0 + self.main_count = 0 + self.h1_count = 0 + self.errors: list[str] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + values = dict(attrs) + if "id" in values and values["id"]: + if values["id"] in self.ids: + self.errors.append(f"duplicate id #{values['id']}") + self.ids.add(values["id"]) + if tag == "a": + self.links.append(values) + elif tag == "img": + self.images.append(values) + elif tag == "title": + self.titles += 1 + elif tag == "main": + self.main_count += 1 + elif tag == "h1": + self.h1_count += 1 + for name in values: + if name.lower().startswith("on"): + self.errors.append(f"inline event handler {name}") + + +def check_page(path: Path) -> list[str]: + source = path.read_text(encoding="utf-8") + parser = PageParser() + parser.feed(source) + errors = list(parser.errors) + relative = path.relative_to(ROOT) + if parser.titles != 1: + errors.append(f"expected one title element, found {parser.titles}") + if parser.main_count != 1: + errors.append(f"expected one main landmark, found {parser.main_count}") + if parser.h1_count != 1: + errors.append(f"expected one h1, found {parser.h1_count}") + if not re.search(r' list[str]: + errors: list[str] = [] + projects = json.loads((ROOT / "data/projects.json").read_text(encoding="utf-8")) + entries = projects.get("projects") + if projects.get("schemaVersion") != 2 or not isinstance(entries, list) or not entries: + return ["data/projects.json: expected schemaVersion 2 and a non-empty projects array"] + ids: set[str] = set() + for index, project in enumerate(entries): + if not isinstance(project, dict): + errors.append(f"data/projects.json: project {index} is not an object") + continue + missing = REQUIRED_PROJECT_FIELDS - project.keys() + if missing: + errors.append(f"data/projects.json: project {index} missing {sorted(missing)}") + project_id = project.get("id") + if project_id in ids: + errors.append(f"data/projects.json: duplicate id {project_id}") + ids.add(project_id) + if not isinstance(project.get("technologies"), list): + errors.append(f"data/projects.json: {project_id} technologies must be an array") + repo = project.get("repositoryUrl", "") + if not isinstance(repo, str) or not repo.startswith("https://github.com/SimpleCaci/"): + errors.append(f"data/projects.json: {project_id} has an unexpected repository URL") + json.loads((ROOT / "data/profile.json").read_text(encoding="utf-8")) + json.loads((ROOT / "data/writings.json").read_text(encoding="utf-8")) + return errors + + +def main() -> int: + errors = [error for page in HTML_FILES for error in check_page(page)] + errors.extend(check_data()) + if errors: + print("Static validation failed:") + for error in errors: + print(f"- {error}") + return 1 + print(f"Static validation passed for {len(HTML_FILES)} pages and project data.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) + diff --git a/sitemap.xml b/sitemap.xml new file mode 100644 index 0000000..92b1362 --- /dev/null +++ b/sitemap.xml @@ -0,0 +1,8 @@ + + + https://simplecaci.github.io/personal-portfolio/ + https://simplecaci.github.io/personal-portfolio/projects/ + https://simplecaci.github.io/personal-portfolio/writings/ + https://simplecaci.github.io/personal-portfolio/contacts/ + + diff --git a/testing.html b/testing.html deleted file mode 100644 index 47dc624..0000000 --- a/testing.html +++ /dev/null @@ -1,72 +0,0 @@ - -
- - - - diff --git a/writings/index.html b/writings/index.html index b0e350f..ccffeac 100644 --- a/writings/index.html +++ b/writings/index.html @@ -1,61 +1,12 @@ - - - - Writings — Quinton - - - - - + Writing - Quinton - -
- -
- -
-
-
- -
- Top Bar Image -

/WRITINGS

-
- -
-
-

Writings

-

Essays, notes, reflections, and anything else I decide to publish.

-
- -
    -
  • -
    - Writing archive in progress - Essays and technical notes will appear here after review. -
    -
  • -
-
- - -
- -
- -
- - - - - - - + + +

Writing / Public notebook

Notes need something worth saying.

I plan to use this space for technical decisions, project lessons, and the uncomfortable middle between an idea and a working system.

No placeholder essays are presented as published work.


Archive status / Preparing

The writing archive is coming soon.

The current data did not contain a finished, reviewable article. Until one exists, this page stays honest rather than filling the shelf with fake titles.

Read the project field notes
+ + +