50 Commits

Author SHA1 Message Date
Greg Kilmer 19999e7104 Merge pull request #13 from Patiun/claude/patiun-rebrand-gjoo9f
Light mode GK monogram and stronger background seals
2026-10-18 16:35:20 -04:00
Claude 813c403fcc Make the background seals a little stronger in light mode
A --gk-ghost token scales the faint edge seals, panel seals and banners:
1 in dark, 1.6 in light, so their lines read on Starlight.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-18 20:34:46 +00:00
Claude 08df07f221 Give the GK monogram a light mode version
In light mode the header, footer and post placeholders show the monogram
on a pale disc with deep gold rings and ink letters, with a soft edge so
it sits apart from the Starlight page. The light file is written by the
light motifs script.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-18 20:30:32 +00:00
Greg Kilmer 98c1d81031 Merge pull request #12 from Patiun/claude/patiun-rebrand-gjoo9f
Light mode with a dark and light toggle
2026-10-18 16:17:06 -04:00
Claude 4614cd25a0 Add a dark and light mode toggle to the header
A sun and moon button at the end of the nav (and the Store's nav) swaps
the mode and remembers the choice in the browser. Until it is pressed the
site follows the device setting. A head script sets html[data-theme]
before first paint, so light styles now key off data-theme instead of a
media query, and motifs render both images with CSS showing one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-18 19:57:05 +00:00
Claude 7aa2910b12 Add light mode that follows the device setting
Dark stays the default. A device set to light gets the picks from the
light motif board: the Dawn chart sky (dawn wash over faint star chart
lines, atlas marks in the hero), deep gold line seals, Midnight, violet and
gold dust, the deep gold to teal filament and the morning sky placeholder.
Large standalone seals (404, Inquiries, Inquiry sent, the blog's lead) are
gold foil in both modes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-18 18:54:56 +00:00
Greg Kilmer 8e5e5cbef8 Merge pull request #11 from Patiun/claude/patiun-rebrand-gjoo9f
Link roundup posts from Shelf cards and keep long link lists short
2026-10-18 13:13:03 -04:00
Claude 57721efbcd Link roundup posts from Shelf cards and keep long link lists short
Shelf game cards show the post about the game, as Library rows already
do. A make's chip prefers its own build log and says Blog for other
posts. Post chips and Connected pages count the links past the first
few, so a roundup linking a hundred items stays readable.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-18 17:09:43 +00:00
Greg Kilmer b3cf6bd02e Merge pull request #10 from Patiun/claude/patiun-rebrand-gjoo9f
Before launch check in admin and an All makes page
2026-10-18 13:02:41 -04:00
Claude f96079f631 Add an All makes page to the Workshop
Every make newest first with its craft, filterable by craft, linked as
See all makes from Recently finished. Makes without a craft now link
here instead of the Workshop page.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-18 16:55:20 +00:00
Claude 02437358cf Add a Before launch placeholder check to admin
Lists pages still showing built-in wording, notes holding placeholder
text, unpublished posts and live notes missing the field their page is
built around, each linked to its editor.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-18 16:52:56 +00:00
Greg Kilmer 2a3a15d7ac Merge pull request #9 from Patiun/claude/patiun-rebrand-gjoo9f
Edit any note and its images from the admin page
2026-10-18 11:02:31 -04:00
Claude 0f78505e99 Edit any note and its images from the admin page
Every note gets an Edit form built from its note type's schema: image
fields show a thumbnail with a pick from the vault or an upload, link
fields offer note names, and lists of rows can be added to, removed and
reordered. A new Site pages view lists each page of the site with its
text and images and the notes that fill it. Image and link fields now
carry a JSON Schema format so a form knows to offer a picker.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-18 14:19:38 +00:00
Greg Kilmer 5077aeacfd Merge pull request #8 from Patiun/claude/patiun-rebrand-gjoo9f
Add the North America managed services role to the resume
2026-10-12 07:24:23 -04:00
Claude 10a7d0a878 Add the North America managed services role to the resume
Implico role becomes "Team Lead, BTP Cloud & Mobile Apps and North
America Managed Services" with a bullet for the practice Greg took on
in June 2026. The resume PDF now tightens spacing when it just spills
onto a second page so it stays on one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-12 04:07:36 +00:00
Greg Kilmer 5442a8d888 Merge pull request #7 from Patiun/claude/patiun-rebrand-gjoo9f
Match the site to the designs, and write posts and manage images in admin
2026-10-11 23:45:51 -04:00
Claude 9390a7cbc0 Open the My world menu over the page
The header clipped everything to its own box, so the dropdown was cut off
below the nav line. Only the nebula and banner are clipped now, and the
header stacks above the page content.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-12 03:45:25 +00:00
Claude a9fb5b3e85 Admin: write blog posts and manage images
A Posts page lists every post with Write a post: title, topic, kind,
date, summary, a cover picked from the vault or uploaded, the pages it is
about, the Markdown body with a rendered preview, and Save draft or
Publish. An Images page uploads several images into an _attachments
folder, shows which notes use each one, copies its embed line, and
deletes unused ones. The content service gains the attachment list,
upload and delete endpoints behind the editor and admin tokens.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-12 03:40:59 +00:00
Claude 151bed230a Match the site to the approved designs
Denser star field, the page nebula, a favicon package, the filament banner
without its box, the About portrait, Kashi and tea photos and Scorpius, the
Store's own header, a header logo glow, a resume PDF that fills its page,
one divider on the Library and Shelf, star-chart art for coverless
favorites, Shelf Players, Play time and blog filters with a Show all fold,
make kind pills on craft pages, and full-width closing bands.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-12 03:33:15 +00:00
Greg Kilmer 3f6167455d Merge pull request #6 from Patiun/claude/patiun-rebrand-gjoo9f
Bambuddy sync: match the real spools API
2026-10-11 22:49:54 -04:00
Claude 449ffcd62d Bambuddy sync: match the real spools API
Checked against Greg's Bambuddy: the key goes in X-API-Key, colors are
color_name (often null on RFID spools) and rgba, grams left is
label_weight minus weight_used, and archived spools carry archived_at.
Unnamed spools get the nearest plain color name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-12 02:47:48 +00:00
Greg Kilmer bd97720be1 Merge pull request #5 from Patiun/claude/patiun-rebrand-gjoo9f
Deploy safety and Homebox tags fallback
2026-10-11 22:29:16 -04:00
Claude 5c3ab14ba3 Homebox sync: fall back to tags when /labels is gone
Newer Homebox releases renamed labels to tags (/api/v1/tags and the
items?tags= filter), so the labels call answered 404.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-12 02:25:12 +00:00
Claude a945bb08cc Deploy: never remove containers outside the stack
deploy.sh ran `docker compose up --remove-orphans` under the project name
gregkilmer. Any other container in a project of the same name, such as the
old site's when its compose file sits in a folder called gregkilmer, counted
as an orphan and was removed. The stack's own services are all defined in
compose.yaml, so the flag isn't needed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-12 02:16:50 +00:00
Greg Kilmer 10f206c756 Merge pull request #4 from Patiun/claude/patiun-rebrand-gjoo9f
Fix the site image build: copy tsconfig.base.json into the build stage
2026-10-11 21:59:58 -04:00
Claude b1f49c63b1 Copy tsconfig.base.json into the site build stage
The schema package's tsconfig extends the root tsconfig.base.json, and
Vite reads it while bundling the site, so the image build failed with
"Tsconfig not found /app/tsconfig.base.json".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-12 01:57:47 +00:00
Greg Kilmer 7706afbdac Merge pull request #3 from Patiun/claude/patiun-rebrand-gjoo9f
Deploy from GitHub with a systemd timer instead of Gitea Actions
2026-10-11 21:01:29 -04:00
Claude dcfd97e823 Deploy from GitHub with a systemd timer instead of Gitea Actions
Cosmos keeps a deploy-only clone of the GitHub repo. gk-deploy.timer runs
pull-and-deploy.sh every 5 minutes: it fetches master and, when it has
moved, runs deploy.sh (build, check, restart, roll back if unhealthy). A
commit that failed is not retried until master moves (FORCE=1 retries), and
DEPLOY_HEALTHCHECK_URL turns red on a failed fetch or deploy. Removes the
Gitea workflow and updates the runbook, README and inventory note.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-12 00:57:18 +00:00
Greg Kilmer 79f1c26426 Merge pull request #2 from Patiun/claude/patiun-rebrand-gjoo9f
Phases 1 to 7: note schema, content service, MCP server, admin page, components, site pages, syncs, forms and Cosmos deploy
2026-10-11 20:51:47 -04:00
Claude 754c6482be Phase 7: Cosmos deployment
- Dockerfile with a check target (typecheck and tests) and one image per
  service on a pinned Node 22.20 Alpine base, each with only its own
  production dependencies and a health check.
- deploy/compose.yaml: gk-content, gk-admin and gk-site on homelab_network
  with no host ports, read-only root filesystems, no capabilities, the vault's
  Site folder mounted into the content service, and backup.dump=sqlite on it.
- deploy/deploy.sh builds and checks the commit, restarts the stack, and goes
  back to the last good tag if it doesn't come up healthy; rollback.sh swaps
  tags. .gitea/workflows/deploy.yml runs it on a host runner on Cosmos.
- docs/deploy.md runbook (settings, runner, mirror, NPM hosts, Turnstile,
  Healthchecks, backups, cutover) and the stack's inventory note in vault-kit.
- Site: trusts X-Forwarded-Host/Proto for gregkilmer.com hosts so form posts
  pass Astro's origin check behind the proxy; rate limits read
  CF-Connecting-IP; site URL set for absolute links.
- Content service: the held-change webhook carries a Discord-ready message;
  .env.example lists the core settings.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-12 00:43:03 +00:00
Claude f9a8fb1acd Resume PDF on the personal letterhead (no Patiun LLC)
Swaps the letterhead copy for Greg's personal version: "Software Developer
· System Architect · Team Lead" in the header and "Greg Kilmer ✦
GregKilmer.com" in the footer.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-12 00:03:58 +00:00
Claude e3b0cf60c5 Phase 6: sync jobs, inquiry and order forms
- Sync scheduler in the content service with a per-job cache (sync_cache),
  sync_runs reporting and Healthchecks pings. Jobs: Archidekt, BoardGameGeek,
  cover art (Open Library, TMDB, AniList), Suwayomi and Kavita reading
  progress, Bambuddy filament and Homebox material stock. A note's own value
  always wins over a synced one.
- PersonalReader: updates only Library notes with a matching anilistId,
  changes to Greg's notes are held for review; Reading series with no note
  show as suggestions on the admin Overview.
- /api/inquiries and /api/orders: Turnstile, rate limits, a saved copy in the
  content service (intake token) plus email over SMTP. Orders are re-checked
  against the live notes: accepted only when the category's ordersOpen is
  true and the line has capacity.
- Admin Inbox for saved submissions. Options' inStock is optional so synced
  stock can fill it. All settings from process.env; .env.example per service.
- Sources could not be reached from the build environment; parsers are tested
  against fixtures labelled as not live.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 23:50:06 +00:00
Claude 60dfeecd7f Reply time setting, and server settings from process.env only
Inquiries and the sent page promise a reply within the site setting
replyTime (2 days, Greg 2026-10-11). The site reads its settings through one
helper that uses process.env only, so a .env file can never be copied into the
built bundle through import.meta.env.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 23:18:07 +00:00
Claude 0d0b8e4085 Resume PDF on the Greg Kilmer letterhead
Each page of the PDF is laid over the brand letterhead with pdf-lib, both
kept as vectors. The letterhead carries the name, email, website and city, so
the resume's own header becomes the title, availability, profiles and date.
Type and two earlier-role lines are tightened so it stays on one page.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 22:36:19 +00:00
Claude a834451def Resume: stronger wording throughout
A review pass over the resume: the summary leads with what Greg does, the
Implico bullets lead with ownership and put Carrier Connect's scale next to
the work, and the earlier roles lead with their strongest title. No new
facts; every number is Greg's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 22:25:46 +00:00
Claude b1aec13eba Resume: customer work, network administration and team management
Adds Greg's sales demos, customer presentations, requirement gathering,
training and network engineering, and the career path, yearly review and
resource planning side of leading the team. Wording is tightened so the PDF
stays on one page, and the PDF skips a role's focus line when it has bullets.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 22:13:55 +00:00
Claude 963ad0df23 Resume from Greg's interview, one-page PDF, store slots closed
The experience notes, skills, education and a new /resume Page text now come
from Greg's Resume Interview answers (tools/migrate/src/seed.ts). Comcast and
Drexel move to a short Earlier list, and the page and PDF gain Selected
projects. Carrier Connect's numbers are Greg's corrected ones.

The PDF fits on one page and carries the email from site settings. Store
lines migrate with no open slots until Greg opens orders.

Schema: experience earlier, page projects, site-settings email.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 21:58:21 +00:00
Claude 99ecdc2113 Phase 5: every page of the site, the resume PDF and RSS
Landing, About, Now, Resume (with a generated PDF), Work and case studies,
Inquiries and the sent page, Library and the Shelf, Workshop and craft pages,
Tabletop, Blog with topics and posts, RSS, the Store, product lines, the
order form and the 404, all rendered from the content service's notes.

Schema: experience previousRoles, skills stack core items, page stats,
make tags and deck favorite. Icon names from notes map to brand icons in one
place, and HTML comments are dropped when Markdown renders.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 21:33:49 +00:00
Claude 7025e95f89 Site app foundation on the content service
services/site is an Astro app rendered on request by the Node adapter. It
keeps every published note in memory from the content service, refreshes
in the background, and keeps serving the last good copy if the service is
down. SiteIndex answers lookups, addresses, links both ways with roll-ups,
the status log and safe Markdown. Vault images pass through /media.
docs/site.md has the URL map and page rules.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 21:15:39 +00:00
Claude 1023882e19 Redraw the leather, round knife and 3D print icons
Leather is now a stretched hide with gold stitching, the round knife has
its crescent blade, gold bevel and handle, and print shows a nozzle laying
a gold layer onto a layered part.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 21:00:45 +00:00
Claude 4c3ff528db Round GK monogram frame, clearer swords and hammer icons
The monogram drops its square background for a round disc with a gold
outer ring and a gold-to-teal filament ring. The crossed swords now have
pointed blades, short guards and gold pommels; the hammer has a face,
a claw and a tapered handle.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 20:56:36 +00:00
Claude 807528a613 Phase 4: component library
The approved element rules and ornament kit as Astro components in
packages/ui: buttons, pills, chips, filters, kickers, notices, fields and
cards; star paths, the header divider, tick scales and rings, seals and
edge seals, gilt corners, dust and placeholder art; the site header,
footer and page shell. Brand fonts are self-hosted. A gallery page shows
everything, and astro check now runs with the root typecheck.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 20:44:16 +00:00
Claude 32cf13a5cb Add the admin page (phase 3)
services/admin is a client of the content service's HTTP API, served on
the tailnet only. It renders on the server with plain forms and no client
scripts, and holds the admin token so the browser never sees it.

- Review queue: reasons, line diff against the live version, accept,
  reject, and reject with the live version put back into Obsidian
- Notes and drafts: search, preview, fields, backlinks, publish toggle
- History with compare and roll back
- Quick capture for a status or a Library item (optionally both)
- Store: orders open or closed per category, open slots and stock
- Overview: counts, last scan and error, scan now, sync job health
- Same-origin check on every post, strict CSP, sanitized previews

The content service gains /api/status, sync run reports (/api/syncs) and
lookup of a note by name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 19:59:21 +00:00
Claude 3f646048a2 Add the content service and MCP server (phase 2)
services/content watches the vault's Site folder, runs every change through
fixed rules, keeps the last accepted version of each note with its history,
and serves one write API. The HTTP API, the MCP server for agents, and the
admin page and editing tools to come all write through it.

- SQLite store with history, review queue, redirects, links and full-text search
- Rules: invalid, misplaced, duplicate name or slug, text loss, secrets,
  agent contact details, agent edits, agent publishing, agent batches
- Watcher with debounce and a periodic full scan
- HTTP API with reader, agent, editor and admin tokens
- MCP tools: search, get_note, get_schema, list_held, set_status,
  add_library_item, update_fields, draft_post
- Tests, including taking in all 545 migrated notes with nothing held

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 19:10:38 +00:00
Claude a2f723ca2d Start the rebuild: note schema, vault kit and data migration
Phase 1 of the functional design plan.

- Move the old site into legacy/, unchanged, for reference. It still
  builds from there until cutover.
- Add packages/schema: the one definition of all 20 note types, with
  field titles and descriptions, note and vault checks, and JSON Schema
  export so later editing tools and the MCP server share it.
- Add vault-kit: Obsidian templates generated from the schema, and the
  Site guide for 24.01_PersonalWebsite.
- Add tools/migrate: turns the legacy JSON, collections, store data and
  page text into 545 Site folder notes and 84 images, and checks the
  result with the schema.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 18:40:55 +00:00
Greg Kilmer 4ded4b277b Merge pull request #1 from Patiun/claude/patiun-rebrand-gjoo9f
Reskin GregKilmer.com in the Patiun brand
2026-10-10 23:11:53 -04:00
Claude 94dfd4df5b Center the platinum glyph in every site motif
The outline step rounded the glyph group's scale (0.29 -> 0.2), shrinking
it and pushing it off center. Motifs are regenerated with full-precision
transforms and the glyph centered on its circle by ink center (brand rule).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 03:02:35 +00:00
Claude 35c09f2854 Brand audit fixes: domain casing, radii, violet, seal size, page seals
- Store domain shown as Store.GregKilmer.com (no uppercase transform)
- Cards and panels 10px corners; buttons pill-shaped
- Canvas star/circuit violet switched to brand violet #6A2FE0
- Nav, store nav and footer seal at the brand's 72px minimum (nav 80px tall)
- Faint seals on contact, store cards, deck boxes, order and professional

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 02:45:49 +00:00
Claude 9ecd4d0e59 Use Patiun icons, motifs, seal logo and gold accents across the site
- Icons: Patiun stroke icon set (13 approved + 73 new) in src/lib/brand-icons.ts;
  Icon.astro and a React BrandIcon resolve mdi names to brand icons, keeping
  mdi only for third-party logos. Inline island icons (mobile menu, carousel,
  form success, board game shelf, product tile) switched too.
- Motifs: layered seal turns in the hero corner; astrolabe on the Professional
  hero; Starlight recolors of the transmutation seal and tria prima array
  behind light sections (outlined ring text, no fonts needed). Removed the
  generic sacred-geometry and astrolabe drawings.
- Gold accents: image frames and corner stars, quote bars, dividers (new
  star-line divider), nav group divider, scrolled nav rule, footer rule.
- Nav and footer use the Patiun seal; nav tagline back to "Developer. Maker.
  Storyteller."; "Web Development · a Patiun LLC company" moved to the
  footer copyright line.
- Mobile menu: Midnight drawer, visible close button.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 02:20:55 +00:00
Claude 01431468e4 Reskin GregKilmer.com in the Patiun brand
- Palette: Patiun Teal, Midnight, night sky, Starlight, flat Alchemist Gold;
  violet/indigo only as nebula light. Starlight content sections, night-sky
  nav and heroes (.on-sky scope re-points accent/gold to on-dark values).
- Type: Cinzel for headings, names and card titles; Montserrat for body, UI
  and eyebrows (font-eyebrow); JetBrains Mono kept for code-like blocks.
- Header lockup: Patiun glyph + Cinzel "GREG KILMER" + "Web Development ·
  a Patiun LLC company"; glyph also marks the footer and store nav.
- Teal filled buttons with Midnight text; gold eyebrows; small links use a
  darker teal so they pass WCAG AA on Starlight.
- Favicons, app icons and web manifest from the GregKilmer.com brand package.
- docs/THEMING.md updated for the new palette and fonts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 01:54:04 +00:00
741 changed files with 42311 additions and 4786 deletions
+11 -15
View File
@@ -1,17 +1,13 @@
# Keep the Docker build context lean. **/node_modules
node_modules **/dist
dist **/.astro
.astro /data
services/*/data
out
.git .git
.gitignore
.env .env
.env.* **/.env
!.env.example **/.env.*
*.log !**/.env.example
npm-debug.log* legacy/node_modules
.DS_Store legacy/dist
.vscode
.idea
docs
*.md
!README.md
+12 -14
View File
@@ -1,30 +1,28 @@
# build output
dist/
.output/
# dependencies # dependencies
node_modules/ node_modules/
# generated types # migration output (it goes into the vault, not the repo)
.astro/ out/
# generated from the schema
packages/schema/json/
# environment # environment
.env .env
.env.* .env.*
!.env.example !.env.example
# logs # logs and editor files
npm-debug.log* npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# macOS / editor
.DS_Store .DS_Store
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json
!.vscode/settings.json !.vscode/settings.json
.idea/ .idea/
data/
# misc # Astro build output and generated types
*.local packages/ui/dist/
packages/ui/.astro/
services/site/dist/
services/site/.astro/
+80 -38
View File
@@ -1,53 +1,95 @@
# ============================================================================= # Images for the GregKilmer.com stack on Cosmos. One file, one target per service:
# Dockerfile — Greg Kilmer Profile Site
# =============================================================================
# Multi-stage build producing a minimal Node server image for the Cosmos homelab.
# #
# Stage 1 (builder): install all deps, run astro build -> dist/server/entry.mjs # docker build --target check . runs `npm run check` (typecheck and tests)
# Stage 2 (runtime): copy only dist + prod node_modules, run the Node server # docker build --target content . the content service and MCP server
# docker build --target admin . the admin page (tailnet only)
# docker build --target site . the public site
# #
# The @astrojs/node standalone adapter emits a self-contained server, so the # deploy/deploy.sh builds check first, so a red check stops a deploy.
# runtime image needs only node_modules + dist. Build once, run anywhere. # See docs/deploy.md.
# =============================================================================
# ---------- Stage 1: build ---------- # Pinned by version (Node 22.20, released 2025-09). Override with --build-arg to
FROM node:22.12.0-alpine AS builder # pull through a mirror.
ARG NODE_IMAGE=node:22.20.0-alpine
# ---------- package manifests, then every workspace's dependencies ----------
FROM ${NODE_IMAGE} AS manifests
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./
COPY packages/schema/package.json packages/schema/
COPY packages/ui/package.json packages/ui/
COPY services/content/package.json services/content/
COPY services/admin/package.json services/admin/
COPY services/site/package.json services/site/
COPY tools/migrate/package.json tools/migrate/
# Install deps. Copy manifests first to leverage Docker layer caching. FROM manifests AS deps
COPY package.json package-lock.json* ./ RUN npm ci --no-audit --no-fund
RUN npm ci --no-audit --no-fund || npm install --no-audit --no-fund --legacy-peer-deps
# Copy source and build. SITE_URL can be overridden at build time. # ---------- typecheck and tests ----------
FROM deps AS check
COPY . . COPY . .
ARG SITE_URL=https://gregkilmer.com RUN npm run check
ENV SITE_URL=$SITE_URL
RUN npm run build
# Prune to production dependencies for the runtime stage. # ---------- the site's server build ----------
RUN npm prune --omit=dev FROM deps AS site-build
# Workspace packages' tsconfigs extend the root one; Vite reads them during the build.
COPY tsconfig.base.json ./
COPY packages packages
COPY services/site services/site
RUN npm run build -w @gk/site
# ---------- Stage 2: runtime ---------- # ---------- each service's production dependencies only ----------
FROM node:20-alpine AS runtime FROM manifests AS content-deps
RUN npm ci --omit=dev --no-audit --no-fund -w @gk/content && npm cache clean --force
FROM manifests AS admin-deps
RUN npm ci --omit=dev --no-audit --no-fund -w @gk/admin && npm cache clean --force
FROM manifests AS site-deps
RUN npm ci --omit=dev --no-audit --no-fund -w @gk/site && npm cache clean --force
# ---------- shared runtime base ----------
FROM ${NODE_IMAGE} AS runtime
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production HOST=0.0.0.0
COPY package.json ./
COPY packages/schema/package.json packages/schema/
COPY packages/schema/src packages/schema/src
# Run as a non-root user for defense in depth. # ---------- content service ----------
RUN addgroup -S app && adduser -S app -G app FROM runtime AS content
COPY --from=content-deps /app/node_modules node_modules
COPY services/content/package.json services/content/
COPY services/content/src services/content/src
# The vault's Site folder and the data folder (SQLite and saved form attachments).
ENV PORT=4310 SITE_DIR=/vault/Site DATA_DIR=/data
EXPOSE 4310
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD wget -qO- http://127.0.0.1:4310/health >/dev/null || exit 1
USER node
CMD ["node", "--disable-warning=ExperimentalWarning", "--import", "tsx", "services/content/src/main.ts"]
# Copy production node_modules and the built server + client assets. # ---------- admin page ----------
COPY --from=builder /app/node_modules ./node_modules FROM runtime AS admin
COPY --from=builder /app/dist ./dist COPY --from=admin-deps /app/node_modules node_modules
COPY --from=builder /app/package.json ./package.json COPY services/admin/package.json services/admin/
COPY services/admin/src services/admin/src
COPY services/admin/public services/admin/public
ENV PORT=4320
EXPOSE 4320
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD wget -qO- http://127.0.0.1:4320/health >/dev/null || exit 1
USER node
CMD ["node", "--import", "tsx", "services/admin/src/main.ts"]
# Astro Node standalone server reads HOST/PORT from env at runtime. # ---------- public site ----------
ENV HOST=0.0.0.0 FROM runtime AS site
COPY --from=site-deps /app/node_modules node_modules
COPY --from=site-build /app/services/site/dist services/site/dist
ENV PORT=4321 ENV PORT=4321
EXPOSE 4321 EXPOSE 4321
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
USER app CMD wget -qO- http://127.0.0.1:4321/ >/dev/null || exit 1
USER node
# Healthcheck: the server responds 200 on /. CMD ["node", "services/site/dist/server/entry.mjs"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- http://127.0.0.1:${PORT}/ >/dev/null 2>&1 || exit 1
CMD ["node", "./dist/server/entry.mjs"]
+38 -141
View File
@@ -1,156 +1,53 @@
# Greg Kilmer — Profile & Portfolio Site # GregKilmer.com
A responsive, content-driven personal site for Greg Kilmer, built as **two wings The site, rebuilt around Greg's Obsidian vault. Notes in the vault's Site
of the same house**: a polished professional resume/portfolio on one side, and a folder are the content; a content service checks every change and keeps the
warm, personality-driven interests hub on the other. last good version; the site serves pages from that service. The full plan,
with the decisions behind it, is the
[Functional Design Plan](https://claude.ai/code/artifact/2a4e8339-7887-4919-a51d-2c11547b80b3).
Built with **Astro 7 + React Islands + TypeScript**, served by a **Node server** ## What is here
with API routes, ready to deploy as a container on a homelab Cosmos system.
--- | Path | What it is |
| --- | --- |
| `packages/schema` | The one definition of every note type: fields, folders, file names, and the checks for one note and for the whole folder. The migration, the content service, the MCP server, the site and any later editing tool all use it. |
| `vault-kit` | What goes into the vault: the Obsidian templates (generated from the schema), the Site guide and the stack's inventory note. |
| `services/content` | The content service: watches the Site folder, runs the rules, keeps the last good version and its history, and serves the one write API. The MCP server for agents is part of it. See [docs/content-service.md](docs/content-service.md). |
| `services/admin` | The admin page: the review queue with diffs, history and rollback, quick capture, store orders, draft previews and sync health. A client of the content service, on the tailnet only. See [docs/admin.md](docs/admin.md). |
| `packages/ui` | The component library: the approved element rules and ornament kit as Astro components, with the brand fonts, seals and icons, and a gallery page that shows them all. See [docs/components.md](docs/components.md). |
| `services/site` | The public site: every page rendered from the content service's published notes, the resume PDF, and the inquiry and order forms. See [docs/site.md](docs/site.md). |
| `deploy`, `Dockerfile` | The Cosmos stack: images, compose file, deploy and rollback scripts, and the systemd timer that deploys each new commit to master. See [docs/deploy.md](docs/deploy.md). |
| `tools/migrate` | The one-time migration from the old site's data into Site folder notes. |
| `docs` | How the pieces fit. Start with [docs/content-model.md](docs/content-model.md). |
| `legacy` | The old site, kept for reference only. Nothing outside it imports from it except the migration. |
## Highlights Coming next: the cutover from the old site (phase 8).
- **Astro 7** static-first core with React Islands for interactivity (mobile ## Commands
menu, theme toggle, animated hero background, contact form).
- **Node backend** (`@astrojs/node`, standalone) with a typed `/api/contact`
endpoint (SMTP via nodemailer + webhook fallback, Zod validation, rate limit).
- **Section-aware theming**: the Professional wing reads dark/polished, the
Interests wing reads warm/inviting, plus a global light/dark toggle.
- **Rich motion**: animated mesh-gradient hero with an optional canvas particle
field, IntersectionObserver scroll reveals, View Transitions page swaps,
scroll-triggered nav blur. All `prefers-reduced-motion` aware.
- **Content Collections** (typed): add projects and experience by dropping
markdown/MDX files.
- **mdi icons everywhere** (no emojis, per the spec), inlined at build time from
`@iconify-json/mdi` for zero runtime cost.
- **Self-hosted variable fonts** (Inter, Plus Jakarta Sans, JetBrains Mono).
- **Accessible**: semantic landmarks, skip link, keyboard-operable menu,
focus-visible rings, ARIA on interactive bits, WCAG-AA contrast targets.
- **Deploy artifacts**: multi-stage `Dockerfile` + `docker-compose.yml`.
--- Node 22 or newer.
## Quickstart ```sh
```bash
# 1. Install dependencies (Node 20+)
npm install npm install
npm run check # typecheck (with astro check) and tests
npm run ui # the component gallery at http://localhost:4321
npm run migrate # writes out/24.01_PersonalWebsite and out/MIGRATION-REPORT.md
npm run templates # regenerates vault-kit templates after a schema change
npm run schema:json # writes a JSON Schema per note type to packages/schema/json/
# 2. Start the dev server with hot reload # the content service, against a copy of the Site folder
npm run dev SITE_DIR=out/24.01_PersonalWebsite/Site DATA_DIR=data \
# -> http://localhost:4321 CONTENT_TOKENS="reader:<24+ chars>,admin:<24+ chars>" npm run content
# 3. Production build (runs astro check + build) # the admin page, in a second terminal
npm run build ADMIN_TOKEN=<the admin token> npm run admin # http://localhost:4320
# 4. Run the built Node server
npm run serve
# -> http://localhost:4321
``` ```
Environment is optional for local dev. To enable contact-form delivery, copy ## The old site
`.env.example` to `.env` and fill in SMTP or webhook vars (see
[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)).
--- The live site still runs from the old code until cutover. It builds the same
way as before, from inside `legacy/`:
## Project structure
```sh
cd legacy && docker compose up -d --build
``` ```
src/
├── components/
│ ├── hero/ AnimatedBackground.tsx (gradient + canvas particles)
│ ├── motion/ Reveal.astro (IntersectionObserver scroll reveals)
│ ├── nav/ NavBar.astro, MobileMenu.tsx, ThemeToggle.tsx
│ ├── cards/ InterestCard.astro
│ ├── professional/ Timeline, SkillGrid, ProjectCard, SectionNav, ContactForm
│ ├── layout/ Footer, SectionHeader, SplitTransition, InterestHero,
│ │ RelatedInterests, Placeholder
│ └── Icon.astro Inline mdi icon renderer (build-time)
├── content/
│ ├── projects/ Project collection entries (markdown/MDX)
│ └── experience/ Experience collection entries
├── layouts/
│ └── BaseLayout.astro <html> shell, head, fonts, theme init, nav, footer
├── pages/
│ ├── index.astro Home (hero + snapshot grid)
│ ├── about.astro About + split transition fork
│ ├── professional/index.astro Single long page: Summary, Experience,
│ │ Skills, Projects, Education, Contact
│ ├── interests/ Hub + 5 sub-pages
│ │ ├── index.astro
│ │ ├── ttrpgs / gaming / reading / making / pokemon .astro
│ ├── contact.astro Two-column connect page + contact form
│ └── api/contact.ts POST endpoint -> SMTP / webhook
├── content.config.ts Collection schemas (projects, experience)
├── lib/ site.ts (nav/socials), motion.ts, content helpers
├── styles/ global.css, themes.css, animations.css
└── scripts/theme-init.ts No-FOUC theme boot (inlined in <head>)
```
---
## The two wings (theming model)
Theming has two independent axes (see [docs/THEMING.md](docs/THEMING.md)):
1. **Global light/dark** on `<html data-theme>`, set before paint by an inline
script, persisted to `localStorage`, toggleable via the nav button.
2. **Section tone** via `data-surface` on `<body>`:
- `neutral` (Home/About/Contact) — balanced middle ground
- `professional` — dark slate, polished
- `interest` — warm cream/amber, inviting
Components only reference semantic CSS variables (`--surface`, `--text`,
`--accent`...), so changing either axis restyles everything automatically.
---
## Adding content
- **Projects / Experience:** drop a markdown or MDX file in
`src/content/projects/` or `src/content/experience/`. Schemas and field
reference are in [docs/CONTENT.md](docs/CONTENT.md).
- **Interest pages:** hand-built `.astro` files under `src/pages/interests/`,
each following the spec's template (Header, Overview, Highlights, Currently,
Related). Edit copy in place.
- **Placeholders:** every `[Greg to fill]` gap from the spec is a styled
`Placeholder` slot with a TODO badge so you can find and replace them.
---
## Deployment (Cosmos homelab)
The site runs as a Node server inside a container. Full walkthrough in
[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md):
```bash
docker compose up -d --build
# Then point your reverse proxy at port 4321 with TLS for gregkilmer.com.
```
---
## Scripts
| Script | Description |
| ----------------- | ----------------------------------------------------- |
| `npm run dev` | Dev server with hot reload |
| `npm run build` | Type-check (`astro check`) then build the Node server |
| `npm run serve` | Run the built server (`dist/server/entry.mjs`) |
| `npm run preview` | Preview the build locally |
| `npm run check` | Type-check only |
---
## Tech stack
Astro 7, React 19, TypeScript 6, Tailwind CSS v4, Framer Motion, nodemailer,
Zod, `@iconify-json/mdi`, Fontsource variable fonts, `@astrojs/node` standalone.
---
## License
Personal site for Greg Kilmer. All rights reserved.
+3
View File
@@ -0,0 +1,3 @@
# Admin page settings. On Cosmos: /home/greg/homelab/gregkilmer/admin.env
# The content service's admin token (the admin:… entry in content.env's CONTENT_TOKENS).
ADMIN_TOKEN=
+66
View File
@@ -0,0 +1,66 @@
# The GregKilmer.com stack on Cosmos: content service, admin page and public site.
#
# Lives at /home/greg/homelab/gregkilmer/compose.yaml (deploy/deploy.sh
# copies it there from deploy/compose.yaml on every build). Settings:
# .env stack settings: GK_TAG, VAULT_SITE, PUID, PGID (deploy/stack.env.example)
# content.env content service secrets (services/content/.env.example)
# admin.env admin page settings (deploy/admin.env.example)
# site.env public site secrets (services/site/.env.example)
#
# No service publishes a host port. Nginx Proxy Manager reaches them by
# container name on homelab_network. See docs/deploy.md.
name: gregkilmer
x-common: &common
restart: unless-stopped
user: "${PUID:-1000}:${PGID:-1000}"
networks: [homelab_network]
read_only: true
tmpfs: [/tmp]
security_opt: ["no-new-privileges:true"]
cap_drop: [ALL]
services:
gk-content:
<<: *common
image: gregkilmer/content:${GK_TAG:?set GK_TAG in .env}
container_name: gk-content
env_file: content.env
environment:
SITE_DIR: /vault/Site
DATA_DIR: /data
volumes:
# The vault's Site folder. Read-write: accepted API and MCP changes are
# written back into the notes.
- ${VAULT_SITE:?set VAULT_SITE in .env}:/vault/Site
# content.db (notes, history, review queue, saved forms, sync cache) and
# submissions/ (form attachments). Both are backed up.
- ./data/content:/data
labels:
backup.dump: sqlite
gk-admin:
<<: *common
image: gregkilmer/admin:${GK_TAG}
container_name: gk-admin
env_file: admin.env
environment:
CONTENT_URL: http://gk-content:4310
depends_on:
gk-content: { condition: service_healthy }
gk-site:
<<: *common
image: gregkilmer/site:${GK_TAG}
container_name: gk-site
env_file: site.env
environment:
CONTENT_URL: http://gk-content:4310
depends_on:
gk-content: { condition: service_healthy }
networks:
homelab_network:
name: homelab_network
external: true
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# Builds the three images for the checked-out commit, runs the checks, and
# restarts the stack on it. If the new containers don't come up healthy, the
# stack goes back to the last good tag. Run from the repo root on Cosmos, by the
# deploy timer (pull-and-deploy.sh) or by hand. See docs/deploy.md.
set -euo pipefail
STACK="${GK_STACK_DIR:-/home/greg/homelab/gregkilmer}"
ENV_FILE="$STACK/.env"
TAG="$(git rev-parse --short=12 HEAD)"
compose() { docker compose --project-directory "$STACK" -f "$STACK/compose.yaml" "$@"; }
setting() { grep -E "^$1=" "$ENV_FILE" | tail -n 1 | cut -d= -f2- || true; }
set_setting() {
if grep -qE "^$1=" "$ENV_FILE"; then sed -i "s|^$1=.*|$1=$2|" "$ENV_FILE"; else echo "$1=$2" >> "$ENV_FILE"; fi
}
for f in .env content.env admin.env site.env; do
[ -f "$STACK/$f" ] || { echo "Missing $STACK/$f (see docs/deploy.md)"; exit 1; }
done
CURRENT="$(setting GK_TAG)"
PREVIOUS="$(setting GK_PREVIOUS_TAG)"
if [ "$CURRENT" = "$TAG" ] && [ "${FORCE:-}" != "1" ]; then
echo "Already running $TAG; nothing to do (FORCE=1 to rebuild)."
exit 0
fi
echo "== Checks"
docker build --target check .
echo "== Images for $TAG"
for t in content admin site; do
docker build --target "$t" -t "gregkilmer/$t:$TAG" .
done
echo "== Deploy"
mkdir -p "$STACK/data/content"
cp deploy/compose.yaml "$STACK/compose.yaml"
set_setting GK_TAG "$TAG"
[ -n "$CURRENT" ] && set_setting GK_PREVIOUS_TAG "$CURRENT"
# No --remove-orphans: another stack may share the project name, and its
# containers must never be touched by this deploy.
if ! compose up -d --wait --wait-timeout 120; then
echo "!! $TAG did not come up healthy"
compose ps
compose logs --tail 50 || true
if [ -n "$CURRENT" ]; then
echo "== Rolling back to $CURRENT"
set_setting GK_TAG "$CURRENT"
set_setting GK_PREVIOUS_TAG "$PREVIOUS"
compose up -d --wait --wait-timeout 120
fi
exit 1
fi
echo "== Prune old images (keeping $TAG and ${CURRENT:-none})"
for t in content admin site; do
docker images "gregkilmer/$t" --format '{{.Tag}}' | while read -r old; do
[ "$old" = "$TAG" ] || [ "$old" = "$CURRENT" ] || docker rmi "gregkilmer/$t:$old" >/dev/null || true
done
done
echo "Deployed $TAG"
+15
View File
@@ -0,0 +1,15 @@
# Deploys GregKilmer.com from the GitHub clone when master moves.
# Install: sudo cp deploy/gk-deploy.{service,timer} /etc/systemd/system/
# sudo systemctl daemon-reload && sudo systemctl enable --now gk-deploy.timer
# Logs: journalctl -u gk-deploy. See docs/deploy.md.
[Unit]
Description=Deploy GregKilmer.com from GitHub
Wants=network-online.target
After=network-online.target docker.service
[Service]
Type=oneshot
User=greg
Environment=GK_STACK_DIR=/home/greg/homelab/gregkilmer
ExecStart=/home/greg/homelab/gregkilmer/repo/deploy/pull-and-deploy.sh
TimeoutStartSec=30min
+9
View File
@@ -0,0 +1,9 @@
[Unit]
Description=Check GitHub for a new GregKilmer.com commit every 5 minutes
[Timer]
OnBootSec=2min
OnUnitInactiveSec=5min
[Install]
WantedBy=timers.target
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Run by gk-deploy.timer on Cosmos: fetches master from GitHub into the
# deploy-only clone and, when it has moved, runs deploy.sh on it. A commit that
# failed to deploy is not retried until master moves again (or FORCE=1).
# Pings DEPLOY_HEALTHCHECK_URL from the stack's .env when set. See docs/deploy.md.
set -uo pipefail
STACK="${GK_STACK_DIR:-/home/greg/homelab/gregkilmer}"
export GK_STACK_DIR="$STACK"
cd "$(dirname "$0")/.."
hc="$(grep -E '^DEPLOY_HEALTHCHECK_URL=' "$STACK/.env" 2>/dev/null | cut -d= -f2- || true)"
ping() { [ -n "$hc" ] && curl -fsS -m 10 --retry 3 "$hc$1" >/dev/null 2>&1 || true; }
if ! git fetch --quiet origin master; then
echo "Could not fetch from GitHub"
ping /fail
exit 1
fi
target="$(git rev-parse --short=12 origin/master)"
running="$(grep -E '^GK_TAG=' "$STACK/.env" 2>/dev/null | cut -d= -f2- || true)"
failed="$(cat "$STACK/.failed-tag" 2>/dev/null || true)"
if [ "$target" = "$running" ] && [ "${FORCE:-}" != "1" ]; then
ping ""
exit 0
fi
if [ "$target" = "$failed" ] && [ "${FORCE:-}" != "1" ]; then
echo "$target failed to deploy before; waiting for a new commit (FORCE=1 to retry)"
ping /fail
exit 0
fi
# The clone is for deploys only, so it always matches master exactly.
git reset --quiet --hard origin/master
git clean -fdq
ping /start
if ./deploy/deploy.sh; then
rm -f "$STACK/.failed-tag"
ping ""
else
echo "$target" > "$STACK/.failed-tag"
ping /fail
exit 1
fi
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Puts the stack back on the previous tag (GK_PREVIOUS_TAG) and swaps the two,
# so running it again rolls forward. See docs/deploy.md.
set -euo pipefail
STACK="${GK_STACK_DIR:-/home/greg/homelab/gregkilmer}"
ENV_FILE="$STACK/.env"
cur="$(grep -E '^GK_TAG=' "$ENV_FILE" | cut -d= -f2-)"
prev="$(grep -E '^GK_PREVIOUS_TAG=' "$ENV_FILE" | cut -d= -f2-)"
[ -n "$prev" ] || { echo "No GK_PREVIOUS_TAG in $ENV_FILE"; exit 1; }
sed -i "s|^GK_TAG=.*|GK_TAG=$prev|; s|^GK_PREVIOUS_TAG=.*|GK_PREVIOUS_TAG=$cur|" "$ENV_FILE"
docker compose --project-directory "$STACK" -f "$STACK/compose.yaml" up -d --wait --wait-timeout 120
echo "Now running $prev (previous: $cur)"
+12
View File
@@ -0,0 +1,12 @@
# Stack settings for compose.yaml. On Cosmos: /home/greg/homelab/gregkilmer/.env
# deploy.sh rewrites GK_TAG on every deploy; GK_PREVIOUS_TAG is the rollback.
GK_TAG=
GK_PREVIOUS_TAG=
# The vault's Site folder on the host.
VAULT_SITE=/home/greg/homelab/obsidian-vault/20-29_Makerspace/24_Homelab_Infrastructure/24.01_PersonalWebsite/Site
# The user the containers run as: the owner of the vault files (id -u greg, id -g greg).
PUID=1000
PGID=1000
# Healthchecks ping for the deploy timer (5-minute period): red when GitHub
# can't be reached or a commit fails to deploy.
DEPLOY_HEALTHCHECK_URL=
+60
View File
@@ -0,0 +1,60 @@
# Admin page
`services/admin` is where Greg decides what the rules held, puts back an
earlier version, captures a status or a Library item, opens and closes store
orders, previews drafts, and checks that the content service and the sync
jobs are running.
It is one more client of the content service's HTTP API, like the MCP server
and any editing tool built later. It writes nothing itself: every action is a
call to the write API, so the same rules apply, and changes are written into
the vault so Obsidian shows them too. Its changes are recorded with the author
`admin` ("Admin page" in history).
## Pages
| Page | What it does |
| --- | --- |
| Overview | Live and total notes, what waits for review, folder problems, the service's last scan and last error, a Scan now button, each sync job's last run and last success, and Library suggestions: series moved to Reading in PersonalReader with no Library item, each with Add to the Library (title, medium, AniList id, state now, published) and Dismiss. |
| Site pages | Each page of the site with its Page text note (lede, photos, portrait) as thumbnails, **Edit page** or **Add page text**, and links to the notes that fill the page (crafts, games, decks, product lines, settings). |
| Before launch | What is still a placeholder, worked out from the notes so it shrinks as they are fixed: pages showing their built-in wording (no Page text note, a draft one, no lede, no About portrait), notes holding a comment, Coming soon, TODO, TBD or lorem, posts not published, and live notes missing the field their page is built around (a craft's blurb, a make's finish date, a deck's take). Each line links to Edit or Add page text. |
| Review | Held changes with the rules' reasons and a line diff against the live version. Accept puts it live (and into the vault, for API changes). Reject keeps the live version; for an edit made in Obsidian, "Reject and put the live version back" also rewrites the file. Earlier decisions are listed by status. |
| Notes, Drafts | Browse and search every note, or only the unpublished ones. Each row has Edit; filtered to one type, the list also offers to add a note of that type. |
| A note | A preview of the body, its fields, what links to it, Edit, Publish or Unpublish, and its history. Any earlier version can be compared with the live one and rolled back. |
| Posts | Every blog post, drafts first, and **Write a post**: title, topic, kind of post, publish date, summary, a cover picked from the vault or uploaded, the pages it is about, Feature it, case study details, and the Markdown body. Preview shows it rendered with its images; Save draft and Publish write the note to `Posts/`. Images added to the post are uploaded to `_attachments/Posts` and appended as `![[file]]` lines. |
| Edit | The form for any note (posts open their own editor). It is built from the note type's schema in `@gk/schema`, so it offers exactly the fields the content service accepts: image fields (`format: vault-image`) show a thumbnail, a pick from the vault and an upload into the type's `_attachments` folder; link fields offer note names; lists of rows (photos, specs, prices, socials) can be added to, removed and moved up or down. An emptied field is removed from the note, an unticked optional box is left out, and fields the form cannot show (a list inside a row) stay as they are. The title is the file name, so it is renamed in Obsidian. |
| Images | Everything in `_attachments`, newest first, by folder, with which notes use each one. Upload several at once into a folder, copy an image's `![[file]]` line, and delete images no note uses. |
| Capture | A status for the Now log, or a Library item with the option to post a status about it in the same step. |
| Store | Orders open or closed per category, with the reopen date and turnaround, and open slots or stock per product line. |
| Problems | Whole-folder problems, like links to notes that do not exist. |
| Inbox | Inquiries and order requests from the site, unhandled first and newest first, with whether each email reached you. Open one to read it, reply by email, download its attachment, and mark it handled or not handled. |
## Safety
- **Tailnet only.** Publish it behind the "Tailnet only" access list; it has no
login of its own.
- **The token stays on the server.** The admin page holds the content service's
admin token and calls the API itself; the browser never sees it.
- **No posts from other sites.** Every form post must carry `Sec-Fetch-Site:
same-origin` (or a matching `Origin`), so a page elsewhere cannot make the
browser act on the admin page.
- **Drafts cannot run code.** Previews show raw HTML as text and drop links that
are not http, https, mailto or relative. The content security policy allows
only the page's own `/admin.js` (Copy buttons and a confirm before deleting
an image), never inline script, and every page still works with plain forms
when that script does not run.
- **Only images go into the vault.** Uploads are checked by extension and by
their first bytes, SVG is refused, and an image is deleted only when no note
uses it.
## Running it
| Variable | Default | |
| --- | --- | --- |
| `CONTENT_URL` | `http://localhost:4310` | The content service. |
| `ADMIN_TOKEN` | required | The content service's admin token. |
| `PORT`, `HOST` | `4320`, `0.0.0.0` | |
```sh
ADMIN_TOKEN=… npm run admin
```
+81
View File
@@ -0,0 +1,81 @@
# Component library
`packages/ui` (`@gk/ui`) holds the approved element rules and ornament kit as
Astro components. The site imports them; nothing else defines how a button,
chip or seal looks. The rules come from the design canvas's Element rules and
Ornament kit boards, approved Oct 11.
Run the gallery with `npm run ui` (or `npm run ui:build` for a static copy in
`packages/ui/dist`). It shows every component inside the real page shell.
## Layout
| Path | What it is |
| --- | --- |
| `src/styles/tokens.css` | Colors, fonts, spacing, radii, control sizes and the page width as `--gk-*` variables. |
| `src/styles/fonts.css` | Cinzel and Montserrat, self-hosted as Latin-subset WOFF2 (OFL, see `src/assets/fonts/LICENSE.md`). |
| `src/styles/base.css` | Page defaults: body, headings, body links, the focus ring, `.gk-wrap`, reduced motion. |
| `src/styles/elements.css` | The element rules as global `gk-*` classes, so Markdown output and plain HTML can use them too. |
| `src/lib/ornaments.ts` | The seeded geometry behind star paths, tick scales, tick rings and dust. Tested in `test/ornaments.test.ts`. |
| `src/components/` | The Astro components below. |
| `src/assets/` | Seals and motifs, the monogram, 86 stroke icons, the card tick strip, and the fonts. |
## Element rules
| Rule | Component | Notes |
| --- | --- | --- |
| I Buttons | `Button` | `variant` primary (teal pill), secondary (gold hairline) or text (filament underline on hover); `size="compact"` for 36px; `href` makes it a link. One primary per section. |
| II States | `StatusPill` | Round pill. `live` teal with a lit dot, `state` without the dot, `favorite` and `current` gold with a star. |
| II Labels | `Chip` | Square 6px chip. `core` gives the teal edge; `href` makes it a page link led by a violet star. |
| III Filters | `FilterGroup` | Separate 36px pills. Items with `href` are links (`aria-current`); items without are toggle buttons (`aria-pressed`) a page script drives. |
| IV Kickers | `Kicker`, `SectionHeading` | Gold capitals over a tick scale. `SectionHeading` adds the Cinzel heading and the filament underline. |
| IV Notices | `Notice` | The ledger note: soft gold panel, gold rule on the left. |
| V Fields | `Field`, `SearchField` | Input, select or text area in the 10px box, filament across the base on focus, with `hint` and `error` wired to `aria-describedby`. |
| VI Cards | `Card` | Linked cards turn gold on hover with a tick scale along the top. `featured` adds the gold border and gilt corners; one per page. |
| VII Links | (base.css) | Body links are teal and always underlined. |
Shared: controls are 44px (36px compact), chips 26px, focus is a 2px gold
ring 3px out, disabled is 40% opacity.
## Ornaments
| Component | Use |
| --- | --- |
| `Star` | The four-point star. Gold, violet (only ever a star), teal, starlight or outline. |
| `StarPath` | Greg's star path line. `strength` calm, standard or wild; the same `seed` always draws the same path (it defaults to the page path). Two per page at most. |
| `Divider` | The header divider: gold line, large center star, star paths both sides. Header and footer only. |
| `HeroHorizon` | Gold hairline with a violet star, where a hero has no divider. |
| `TickScale` | Astrolabe ticks; with `value` it is a meter. |
| `TickRing` | A tick ring around a step number or small seal; `medallion` adds the disc and glow. |
| `SlotMeter` | Open order slots as stars over a tick meter. |
| `GiltCorners` | Corner brackets for the one featured panel per page. |
| `Seal`, `EdgeSeals` | A seal as an image; `EdgeSeals` places the page family's seal at the page edges at 6 to 7%. |
| `ConstellationDust` | Sparse static specks, about one in five gold. |
| `StarChartArt` | Placeholder art and the fallback for a missing image: star-chart grid, gold mat, a seal. |
| `Icon` | One of the 86 brand icons, in the current text color. |
Each page family's edge seal is in `src/components/seals.ts` (`pageSeals`):
landing uses transmutation star paths, resume the Kepler nest, work the
squared circle, and so on. A craft page passes its own craft seal.
## Page shell
`PageShell` is every page's frame: the head (title, description), dust and
edge seals, `SiteHeader` (nebula, filament banner, the primary nav with the My
world menu, an optional hero in the `hero` slot, the divider), the page
content, and `SiteFooter`. The nav entries live in `src/components/nav.ts`;
pass `current` to mark the page's entry.
```astro
---
import PageShell from "@gk/ui/components/PageShell.astro";
---
<PageShell title="Blog" family="blog" current="blog" description="What I am making, learning, playing and building.">
<Fragment slot="hero">…</Fragment>
…
</PageShell>
```
The site follows the device's light or dark setting until the visitor presses the sun and moon toggle in the header (`ThemeToggle`), which saves their choice in the browser. A script in PageShell's head sets `<html data-theme="light">` or `"dark"` before the page paints; without script the site stays dark. Style light mode with `:root[data-theme="light"]` (in a component, `:global(:root[data-theme="light"]) .x`), not a `prefers-color-scheme` media query, so the toggle can override it. Colors come from the tokens in `tokens.css`, which has the light values too, so a component that uses `var(--gk-…)` needs nothing more. For a translucent color use a channel token, as in `rgba(var(--gk-gold-rgb), 0.35)`, never a hardcoded rgba. In SVG markup use `style="fill:var(--gk-gold)"`.
Motif images come in a light recolor and, for seals, a gold foil version for each mode. Show a motif with `Motif` (or `Seal`), which renders both files and shows the one for the current mode; a plain `<img>` stays dark. Pass `foil` only to a seal standing alone at 120px or more and half opacity or more. After changing a motif SVG, run `node packages/ui/scripts/light-motifs.mjs` to rewrite the `light/`, `foil/` and `foil-light/` versions.
+48
View File
@@ -0,0 +1,48 @@
# Content model
Everything the site shows is a Markdown note with YAML frontmatter in the
vault at `20-29_Makerspace/24_Homelab_Infrastructure/24.01_PersonalWebsite/Site/`.
`24.02_PatiunWebsite` is reserved for the Patiun site later.
## Rules every note follows
- A note's `type` field says what it is. The types, their fields and their
folders are defined once, in `packages/schema/src/types.ts`.
- A note is live only when `publish: true`. Missing means false.
- Links between notes are Obsidian wikilinks, `[[Note name]]`, in frontmatter
or in the body. A link resolves by file name, so file names are unique across
the whole Site folder.
- Images live in `Site/_attachments/` and are linked the same way,
`[[galaxy-dice.jpg]]`, or are full https URLs.
- Folders starting with `_` hold templates and attachments, never notes.
Files starting with `_` are single settings notes: `_site.md`,
`Now/_kinds.md`, `Store/_settings.md`.
- Slugs come from the title unless a note sets `slug`. They are unique within
a type.
## Note types
| Type | Folder | Notes |
| --- | --- | --- |
| `post` | `Posts/` | Blog posts. `postType: case study` also appears on the Work page. |
| `library-item` | `Library/<Medium>/` | Books, light novels, webnovels, manga, manhwa, video games, anime, TV, movies. |
| `board-game` | `Shelf/` | The Shelf. Players and play time sync from BoardGameGeek by `bggId`. |
| `craft`, `station`, `tool`, `make` | `Workshop/…` | The Workshop. |
| `tabletop-system`, `campaign`, `card-game`, `deck` | `Tabletop/…` | Tabletop. Magic decks sync from Archidekt. |
| `experience`, `skills`, `education` | `Resume/…` | The resume page and its PDF. |
| `status`, `status-kinds` | `Now/` | The status log behind Now, the Landing strip and Now chips. |
| `product-line`, `store-settings` | `Store/…` | The Store. |
| `site-settings` | `_site.md` | Name, tagline, location and social links. |
| `page` | `Pages/` | The written introduction for a page. |
Each field's title and description live in the schema too, so the
JSON Schema export (`npm run schema:json`) can drive a form or explain a field
to an agent.
## Designed for editing tools
The content service will be the only writer apart from Obsidian. It will
expose one write API, built on the schema's `checkNote`, `writeNote` and
`checkVault`. The MCP server for agents is one client of that API; the admin
page is another; any editing tool built later is a third. None of them define
fields or rules of their own.
+367
View File
@@ -0,0 +1,367 @@
# Content service
The content service sits between the vault and everything that shows or
changes content. It is the only thing that reads or writes the Site folder.
```
Obsidian ──edits──▶ Site folder ──watch──▶ rules ──▶ accepted store ──▶ site, admin page
▲ ▲
└──── accepted API changes ────────┤
write API (HTTP)
▲ ▲
MCP server admin page, later editors
```
- **The vault is the source of truth.** Greg edits in Obsidian; the service
watches the folder and takes each change in.
- **Every change runs the same rules**, whether it came from Obsidian, an
agent or the admin page. A change that passes is accepted. A change that
fails is held for review, and the site keeps showing the last accepted
version, so a bad edit never reaches a page.
- **Changes through the API are written back into the vault** once accepted,
so Obsidian always shows what the site shows. The service marks its own
writes as seen before writing, so the watcher does not take them in twice.
- **Deleting or moving a file unpublishes the note.** Its history stays, and
any version can be rolled back.
## The rules
Pure functions in [`services/content/src/rules.ts`](../services/content/src/rules.ts),
run in this order. The rules live in code, not in the vault, so no agent can
loosen them.
| Code | Holds a change when |
| --- | --- |
| `invalid` | The note fails its type's schema. |
| `misplaced` | A settings note (`_site`, `Now/_kinds`, `Store/_settings`) is not at its fixed path. |
| `duplicate-name`, `duplicate-slug` | Another live note already has the file name, or the same type already uses the address. |
| `text-loss` | A body of 400 or more characters loses more than half in one edit. |
| `secret` | New text looks like a key, token or password. |
| `contact` | An agent adds an email address or phone number. |
| `agent-edit` | An agent changes a note it did not write, unless the note has `agentEdits: true`. |
| `agent-publish` | An agent publishes anything but a status or a Library item. |
| `batch` | An agent makes more than 10 changes in 10 minutes. |
A sync job writing notes (author `sync`) runs every agent rule above, the
`batch` limit counted for syncs on their own; see [Sync jobs](#sync-jobs).
Invalid input through the API is turned down at once with the problems, rather
than held, so callers fix it themselves. A change from Obsidian that fails is
held, because the file is already on disk.
## Addresses
A slug change or a rename keeps the old address working as a redirect. When a
note disappears and a note of the same type with the same text or title
appears within ten minutes, that is treated as a move: redirects to the old
path are pointed at the new one, so redirects never chain. A slug that comes
back into use belongs to its note, not to the redirect.
## The write API
`ContentService.apply` takes one change, `create` (type, fields, body) or
`update` (path, the fields to change, `null` to remove one, and optionally
the body), along with who made it. Every client uses it:
- the MCP server, as `agent`;
- the admin page and later editing tools, as `greg` (editor token) or `admin`;
- the review queue: `acceptHeld`, `rejectHeld` (optionally restoring the live
version into the vault), and `rollback` to any earlier revision.
An update first takes in any unsaved Obsidian edit to that file, so an API
change never overwrites one. A note with a change waiting for review takes no
other API change until that one is decided. A note's type never changes.
### HTTP
Every route but `/health` needs `Authorization: Bearer <token>`. Tokens map to
roles in `CONTENT_TOKENS`.
| Route | Who | What |
| --- | --- | --- |
| `GET /health` | anyone | Note, published and held counts. |
| `GET /api/schema` | all | Every note type with its JSON Schema. |
| `GET /api/notes?type=&q=&limit=&offset=&full=` | all | List or search. Readers see published notes only. With `full=true` each note carries `synced` (see [Sync jobs](#sync-jobs)). |
| `GET /api/note?path=`, `?name=` or `?type=&slug=` | all | One note with its backlinks and `synced`. An old slug answers with `{ redirect }`. |
| `GET /api/backlinks?name=` | all | Notes linking to a note. |
| `GET /api/redirects` | all | Every redirect. |
| `GET /attachments/<file>` | all | An image from `_attachments`. |
| `GET /api/note/history?path=`, `/api/note/revision?path=&rev=` | agent, editor, admin | History and old versions. |
| `GET /api/problems` | agent, editor, admin | Broken links and other whole-folder problems. |
| `GET /api/held`, `/api/held/<id>` | agent, editor, admin | The review queue; one entry comes with the current text for comparison. |
| `GET /api/status` | agent, editor, admin | When the service started, its last full scan and last error, counts, and each sync job's latest run. |
| `GET /api/syncs`, `?job=` | agent, editor, admin | Each sync job's latest run, or one job's recent runs. |
| `POST /api/syncs` | agent, editor, admin | A sync job reports a run: `{ job, ok, message?, counts? }`. The last 50 runs per job are kept. |
| `POST /api/changes` | agent, editor, admin | One change. 422 with reasons when turned down. |
| `POST /api/held/<id>/accept`, `/reject` | editor, admin | Decide a held change. `{ "restore": true }` on reject puts the live version back into the vault. |
| `POST /api/note/rollback` | admin | `{ path, rev }` |
| `POST /api/scan` | editor, admin | Compare the whole folder now. |
| `GET /api/attachments` | editor, admin | Every file in `_attachments`, newest first, with its folder, size and the notes whose text names it (`usedBy`). |
| `POST /api/attachments?folder=&name=` | editor, admin | The raw bytes of a PNG, JPEG, WebP, GIF or AVIF image (up to 15 MB; SVG is refused because it can carry script). It is saved in `_attachments/<folder>` under a lowercase, hyphenated name, with `-2`, `-3` added when the name is taken anywhere in `_attachments`. Answers `{ name }`. |
| `POST /api/attachments/delete` | editor, admin | `{ name }`. Refused with 409 while any note still uses the file. |
| `POST /api/submissions` | intake, admin | File a saved copy of an inquiry or order. Answers `{ id, receipt }`. |
| `POST /api/submissions/<id>/delivery` | intake (with the receipt, once), admin | `{ receipt, status: sent, failed or skipped, error? }`: how the email went. |
| `GET /api/submissions?handled=`, `/api/submissions/<id>` | admin | Saved copies, unhandled first, newest first, with counts. |
| `POST /api/submissions/<id>/handled` | admin | `{ handled: true or false }` |
| `GET /api/submissions/<id>/attachment` | admin | The attached file, always as a download. |
| `/mcp` | agent, editor, admin | The MCP server. |
### MCP
Streamable HTTP at `/mcp`, stateless. Tools: `search`, `get_note`,
`get_schema`, `list_held`, `set_status`, `add_library_item`, `update_fields`
and `draft_post` (always unpublished). There is no tool to delete, rename,
move or write a raw file.
## Running it
| Variable | Default | |
| --- | --- | --- |
| `SITE_DIR` | required | The vault's Site folder. |
| `DATA_DIR` | `data` | Holds `content.db`. Back it up: history and the review queue cannot be rebuilt from the vault. |
| `PORT`, `HOST` | `4310`, `0.0.0.0` | |
| `CONTENT_TOKENS` | none | `role:token` pairs, comma separated. Roles: reader, agent, editor, admin, intake. Tokens are 24 characters or more. |
| `ALLOWED_EMAILS` | none | Addresses an agent may add without a hold. |
| `HEALTHCHECK_URL` | none | Pinged after every full scan. |
| `NOTIFY_WEBHOOK_URL` | none | Receives `{ content, event: "held", held }` when a change is held. `content` is a one-line message, so a Discord webhook URL works as is. |
| `SCAN_MINUTES` | `5` | Full scan interval, on top of the file watch. |
| `SUBMISSION_MAX_MB` | `10` | Largest attachment a form submission may carry. |
| `SYNC_JOBS` | none | Public sync jobs to run, comma separated: `archidekt`, `bgg`, `covers`. See [Sync jobs](#sync-jobs). |
| `SYNC_<JOB>_MINUTES` | per job | Overrides a job's interval, e.g. `SYNC_BGG_MINUTES=720`. |
| `SYNC_<JOB>_HEALTHCHECK_URL` | none | The job's Healthchecks ping URL, e.g. `SYNC_ARCHIDEKT_HEALTHCHECK_URL`. |
| `BGG_TOKEN` | none | BoardGameGeek application token, sent as `Authorization: Bearer`. Register an app with BGG if the XML API answers 401. |
| `TMDB_API_KEY` | none | TMDB v3 API key or v4 read access token. Without it, film and TV covers are skipped. |
```sh
SITE_DIR=/path/to/Site CONTENT_TOKENS="reader:…,agent:…,editor:…,admin:…" npm run content
```
The store is SQLite (`node:sqlite`) with full-text search, so the service has
no database server to run.
## Sync jobs
Outside sources sync on a schedule into a cache the site reads, so a source
being down never takes a page down. Code: [`services/content/src/sync/`](../services/content/src/sync/).
| Job | Every | Reads | Stores per note |
| --- | --- | --- | --- |
| `archidekt` | hour | Deck notes with `archidektId`: `GET https://archidekt.com/api/decks/<id>/` | `commanderArt` (Scryfall art crop of the commander, else the deck's featured image), `colors` (color identity, WUBRG letters), `commanders`, `deckSize` (cards, maybeboard excluded), `format`, `deckUrl` |
| `bgg` | day | Board-game notes' `bggId`: XML API2 `thing`, 20 ids a request, 5 s apart, waits out `202` | `minPlayers`, `maxPlayers`, `playTime`, `minPlayTime`, `maxPlayTime`, `cover` (box art) |
| `covers` | 15 min | Library items with no `cover`: Open Library (books), TMDB (film, TV; needs `TMDB_API_KEY`), AniList (anime, manga, manhwa, light novels, webnovels; by `anilistId` when set) | `cover` |
| `suwayomi` | hour | PersonalReader's manga and manhwa: Suwayomi's GraphQL library (category, furthest chapter read and when, AniList tracker id), matched to Library items by `anilistId`; AniList for covers | `chapterProgress` (`title`, `status`, `chapter`, `readAt`), `cover` (AniList); changes `state` (below) |
| `kavita` | day | PersonalReader's light novels and books: Kavita's API, matched by `kavitaSeriesId`, or by `anilistId` against an anilist.co link in the series' metadata | `bookProgress` (`title`, `percent`, `readAt`); changes `state` (below) |
| `bambuddy` | hour | Filament spools in stock | `filamentStock` on product lines with a Filament option |
| `homebox` | hour | Homebox items with the labels or locations in `HOMEBOX_LABELS` / `HOMEBOX_LOCATIONS` | `materialStock` on product lines with a Leather or Paper option |
The covers job looks every 15 minutes, so a new Library item gets a cover
soon after it appears, and sweeps slowly: at most 20 items a run, an item
with no cover found is asked again after a day, one with a cover after 30
days. Only image addresses are stored; nothing is downloaded. Video games
have no cover source.
### Homelab sources (Cosmos)
These four run on Cosmos over `homelab_network`, are **read only** against
their source, and are enabled by their own URL and login alone (not
`SYNC_JOBS`). Code: `sync/jobs/{suwayomi,kavita,bambuddy,homebox}.ts`,
settings in `sync/jobs/homelab.ts`. None of them has been run against a real
server from this repository: they are written from each project's API
documentation (Bambuddy: from nothing, see below) and tested with
hand-written sample payloads in `services/content/test/sync/fixtures`.
**PersonalReader (Suwayomi, Kavita)** is private. A job touches a Library
item only when the note carries the series' id: `anilistId` (Suwayomi's
AniList tracker record; Kavita's metadata link), or `kavitaSeriesId`. It never
matches by title. It caches only a title, a chapter or percent, when it was
read and an AniList cover: never a source site, a scan group, a thumbnail or
any address inside Suwayomi or Kavita.
| Reader says | Library `state` |
| --- | --- |
| Suwayomi category Reading | `now` (a note at `caught up` stays) |
| Plan to read | `up next` |
| Completed | `finished` |
| Dropped, or any other category | left alone |
| Kavita: every page read | `finished` when the series is Completed or Ended, else `caught up` (a note at `finished` stays) |
| Kavita: some pages read | `now` (a note at `caught up` stays) |
| Kavita: unread and on Want to Read | `up next` |
State changes go through `ctx.apply` as `sync`, so a note Greg wrote is held
for review (`agent-edit`), as is everything else in the rules above. A job
proposes each change once: it is not proposed again while it waits, nor after
Greg rejects it, until the reader says something different (the job keeps
that memo in its cache under `_memo`, which is not a note and never reaches
the site).
- **Suwayomi** logs in with its own read-only account. `SUWAYOMI_AUTH=ui_login`
(the default with a user set) uses the GraphQL `login` mutation and sends the
access token as `Authorization: Bearer`; `basic` sends HTTP basic auth (for
`AUTH_MODE=basic_auth`); `none` sends nothing. Suwayomi's cookie-based
`simple_login` mode is not supported. It then reads `categories { nodes {
name mangas { nodes { title latestReadChapter { chapterNumber lastReadAt }
trackRecords { nodes { trackerId remoteId title lastChapterRead } } } } } }`
from `<SUWAYOMI_URL>/api/graphql`; the AniList record is tracker id 2. Covers,
titles and media for suggestions come from one batched AniList query.
- **Library suggestions.** A Reading series with an AniList id and no Library
item is listed at `GET /api/suggestions` (editor, admin, agent) and on the
admin Overview, with one button that creates the Library item (title,
medium from AniList, `anilistId`, `state: now`, published) through
`POST /api/changes`, and a Dismiss (`POST /api/suggestions/<key>/dismiss`,
editor or admin; kept in the `suggestion_dismissals` table). The list itself
is the job's cache entry `_suggestions`; a series leaves it as soon as a
Library item carries its AniList id.
- **Kavita** exchanges `KAVITA_API_KEY` at `POST /api/Plugin/authenticate` for
a token, reads `GET /api/Series/<id>`, `GET /api/Series/metadata?seriesId=`,
`POST /api/Series/all-v2` (only when a note needs an AniList match; one
metadata request per series, a quarter second apart) and
`POST /api/want-to-read/v2`. AniList matching applies to book, light novel
and webnovel notes only, so a manga note is Suwayomi's.
**Store stock (Bambuddy, Homebox)** never writes notes. A job stores what is
in stock on every product line that has an option in a group it feeds
(`filamentStock` for Bambuddy's group, Filament by default;
`materialStock` for Homebox's), as `[{ group, options: [{ name, hex?, inStock }] }]`.
The site lays it under the note (`optionGroups` in
`services/site/src/lib/store.ts`, which the Store pages and the order check
both use): an option's own `inStock` and `hex` always win; an option that
leaves `inStock` out (it is optional) takes the synced value, and is out of
stock when the source does not have it; in-stock colors the note does not
list are added after the note's own. With nothing synced, options show
exactly as the note says (a missing `inStock` counts as in stock).
- **Bambuddy** reads `GET /api/v1/inventory/spools` (`BAMBUDDY_SPOOLS_PATH`)
with an API key made in Bambuddy's Settings, sent in the `X-API-Key` header.
Field names were checked against Greg's Bambuddy (2026-10-12) and are all in
`mapBambuddySpools` in `sync/jobs/bambuddy.ts`. Grams left is `label_weight`
minus `weight_used`; archived spools are skipped. A spool with no
`color_name` (RFID spools) is named by its nearest plain color (Black,
Blue...); setting a color name in Bambuddy overrides that. A color is in
stock when some spool of it has at least `BAMBUDDY_MIN_GRAMS` (default 50)
left.
- **Homebox** logs in at `POST /api/v1/users/login`, looks up the label and
location ids, and lists `GET /api/v1/items?labels=<id>` (or
`locations=<id>`). Each `HOMEBOX_LABELS` / `HOMEBOX_LOCATIONS` entry is
`name` or `name=Group` (`leather` feeds Leather). An item is in stock when
its quantity is above zero; a `#rrggbb` in its description is its swatch
color. A name that matches no label or location fails the run.
| Variable | |
| --- | --- |
| `SUWAYOMI_URL` | Enables `suwayomi`, like `http://suwayomi:4567`. |
| `SUWAYOMI_USER`, `SUWAYOMI_PASS` | Its own read-only Suwayomi account. |
| `SUWAYOMI_AUTH` | `ui_login` (default with a user), `basic`, or `none`. |
| `KAVITA_URL`, `KAVITA_API_KEY` | Enable `kavita`, like `http://kavita:5000`; the key of a Kavita user that can see the libraries. |
| `BAMBUDDY_URL` | Enables `bambuddy`. |
| `BAMBUDDY_SPOOLS_PATH` | Endpoint path (default `/api/v1/inventory/spools`). |
| `BAMBUDDY_API_KEY`, `BAMBUDDY_AUTH_HEADER` | The API key from Bambuddy's Settings, sent in `X-API-Key` (or in the named header). |
| `BAMBUDDY_GROUP`, `BAMBUDDY_MATERIALS`, `BAMBUDDY_MIN_GRAMS` | Option group (Filament), materials to include (like `PLA,PETG`; all when empty), grams that count as in stock (50). |
| `HOMEBOX_URL`, `HOMEBOX_USER`, `HOMEBOX_PASS` | Enable `homebox`, with a read-only Homebox user. |
| `HOMEBOX_LABELS`, `HOMEBOX_LOCATIONS` | Which items count, `name` or `name=Group`, comma separated. Labels default to `leather,paper`. |
Each also takes `SYNC_<JOB>_MINUTES` and `SYNC_<JOB>_HEALTHCHECK_URL` like any
job (`SYNC_SUWAYOMI_HEALTHCHECK_URL`, and so on).
### How they run
- **Enabled only when configured.** The public sources run when named in
`SYNC_JOBS`. A job that needs a URL or a login is enabled by those
variables alone.
- **Scheduler.** Each job runs once shortly after start (20 seconds apart,
so they do not all start together), then every interval. A job never runs
twice at once; a run past its timeout (10 minutes unless the job says
otherwise) is aborted and reported as failed.
- **Reporting.** Every run lands in `sync_runs` (`/api/syncs`, the admin
Overview). With `SYNC_<JOB>_HEALTHCHECK_URL` set, the job pings
`<url>/start`, then `<url>` or `<url>/fail` with the message, as the nightly
backup does.
- **The cache.** Table `sync_cache` in `content.db`, keyed by job and note
path, holding the values as JSON and when they were fetched. A job replaces
a note's values only with values it fetched successfully, so a failed run
or a failed item keeps the last good values. Values for notes that leave a
job's scope (a deck loses its `archidektId`) are dropped. The cache can be
rebuilt by running the jobs again.
### What the site gets
`/api/notes?full=true` and `/api/note` give every note a `synced` object:
each job's values for that note merged together (jobs do not share keys).
The site lays `synced` under the note's `data`, so **a value set in the note
always wins** (a note's own `cover` beats a synced one), and a note with
nothing synced renders exactly as before.
### Writing a job
A job is a `SyncJob` ([`sync/types.ts`](../services/content/src/sync/types.ts)),
added to `enabledJobs` in [`sync/index.ts`](../services/content/src/sync/index.ts)
behind the settings it needs (read in [`sync/settings.ts`](../services/content/src/sync/settings.ts)
from the environment, and documented above):
```ts
interface SyncJob {
name: string; // "bambuddy": sync_runs, the cache and SYNC_BAMBUDDY_* use it
everyMinutes: number;
timeoutMs?: number; // default 10 minutes
healthcheckUrl?: string;
run(ctx: SyncContext): Promise<{ message?: string; counts?: Record<string, number>; ok?: boolean }>;
}
```
`run` gets a `SyncContext` and nothing else:
| | |
| --- | --- |
| `ctx.notes(type)` | Live notes of a type, read only. |
| `ctx.cached(path)`, `ctx.set(path, values)` | This job's cache for a note. Call `set` only with values fetched successfully. |
| `ctx.retain(paths)` | Drop this job's values for notes outside `paths`. |
| `ctx.apply(change)` | Propose a note change as `sync` (below). |
| `ctx.fetch(url, init)` | `fetch` with the sync User-Agent, the run's abort signal and a 30 second timeout (`timeoutMs` to change). |
| `ctx.sleep(ms)`, `ctx.signal`, `ctx.now()`, `ctx.log(msg)` | Polite pauses, cancellation, the clock, logging. |
Throw to fail the whole run; return `ok: false` when some items failed. A run
that throws changes nothing already cached.
**Read-only sources** (Bambuddy filament, Homebox materials) store what they
read with `ctx.set` on the note it belongs to and add the keys to the site's
readers; they never write notes.
**Jobs that change notes** (Suwayomi and Kavita progress for PersonalReader)
call `ctx.apply({ op: "update", path, fields })`, the same
`ContentService.apply` every client uses, with author `sync`, so the note
gets `updatedBy: sync`. A sync is held to every agent rule and can do nothing
an agent cannot:
- it cannot delete, move or rename a note, or change its type;
- a change to a note the sync did not write (Greg's, an agent's) is held for
review unless the note has `agentEdits: true`;
- added email addresses or phone numbers, anything that looks like a secret,
and large text loss are held;
- it may publish only statuses and Library items; anything else is held;
- more than 10 sync changes in 10 minutes are held (`batch`), so a job that
updates many notes should change only what actually differs.
The outcome says `accepted`, `held` or `rejected`; count those in the run's
`counts`. A note with a change waiting for review takes no further change
until Greg decides it, which `apply` answers as `rejected` with `waiting`.
## Form submissions
The site's Inquiries and Store order forms are emailed to Greg and saved here
as well (`services/content/src/submissions.ts`), so a lost email never loses a
request. Each copy keeps its kind (inquiry or order), type (inquiry type or
product line), name, email, the answers in form order, the attachment if any,
when it came in, how its email went (`pending`, `sent`, `failed`, `skipped`
when SMTP is not set up) and whether Greg has handled it.
- The site files copies with an `intake` token. That token can do nothing
else: every other route, `/mcp` included, answers 403, and it can never read
a copy back, not even the one it just filed. It reports the email result
once, with the receipt it got when filing.
- Only the `admin` token lists, reads, downloads and marks copies handled
(the admin page's Inbox).
- Attachments are kept under `DATA_DIR/submissions` with a name the service
makes up (id, random part, a known extension), up to `SUBMISSION_MAX_MB`.
Allowed: png, jpg, jpeg, gif, webp, avif, heic, pdf, txt, csv, dek, dec. They
are only ever served as downloads.
- The rows live in `content.db`, so the `backup.dump=sqlite` label covers
them; back up `DATA_DIR/submissions` with the volume too.
+135
View File
@@ -0,0 +1,135 @@
# Deploying on Cosmos
The stack is three containers on `homelab_network`, none with a host port:
| Container | Image target | Port | Reached as | Who can reach it |
| --- | --- | --- | --- | --- |
| `gk-content` | `content` | 4310 | `content.gregkilmer.com` (API and `/mcp`), `http://gk-content:4310` on homelab_network | Tailnet only |
| `gk-admin` | `admin` | 4320 | `admin.gregkilmer.com` | Tailnet only |
| `gk-site` | `site` | 4321 | `beta.gregkilmer.com` until cutover, then `gregkilmer.com` | Tailnet only until cutover, then public through Cloudflare |
The old site keeps serving gregkilmer.com until phase 8 (cutover).
Files in the repo:
- `Dockerfile`: one target per service, plus `check` (typecheck and every test). Node is pinned to `node:22.20.0-alpine`.
- `deploy/compose.yaml`: the stack. Containers run as the vault's owner, with a read-only root filesystem, no capabilities and `no-new-privileges`.
- `deploy/deploy.sh`: builds `check`, then the three images tagged with the commit, and restarts the stack. If the new containers aren't healthy within two minutes, it puts the last good tag back. Run it again on the same commit and it does nothing (`FORCE=1` rebuilds).
- `deploy/rollback.sh`: switches to the previous tag. Run it again to roll forward.
- `deploy/pull-and-deploy.sh`, with `deploy/gk-deploy.service` and `gk-deploy.timer`: every 5 minutes, Cosmos fetches master from GitHub into its own clone of the repo and runs `deploy.sh` when master has moved. A commit that failed to deploy isn't retried until master moves again.
## On Cosmos
Everything lives in `/home/greg/homelab/gregkilmer`:
```
/home/greg/homelab/gregkilmer/
repo/ clone of github.com/Patiun/com.gregkilmer.www, for deploys only
compose.yaml copied from deploy/compose.yaml by every deploy
.env GK_TAG, GK_PREVIOUS_TAG, VAULT_SITE, PUID, PGID, DEPLOY_HEALTHCHECK_URL
content.env content service secrets and sync settings
admin.env ADMIN_TOKEN
site.env site secrets: tokens, Turnstile, SMTP
data/content/ content.db and submissions/ (backed up)
```
Kopia already covers `/home/greg`, so the `.env` files and `data/` are in the encrypted backups.
## First-time setup
1. **Clone and settings.** The repo is private, so give Cosmos read-only access with a deploy key:
```sh
ssh-keygen -t ed25519 -N "" -C cosmos-gregkilmer-deploy -f ~/.ssh/gregkilmer_deploy
```
Add `~/.ssh/gregkilmer_deploy.pub` on GitHub under the repo's Settings, then Deploy keys, with write access left off. Then:
```sh
mkdir -p /home/greg/homelab/gregkilmer/data/content
cd /home/greg/homelab/gregkilmer
GIT_SSH_COMMAND="ssh -i ~/.ssh/gregkilmer_deploy -o IdentitiesOnly=yes" \
git clone git@github.com:Patiun/com.gregkilmer.www.git repo
git -C repo config core.sshCommand "ssh -i ~/.ssh/gregkilmer_deploy -o IdentitiesOnly=yes"
cp repo/deploy/stack.env.example .env
cp repo/services/content/.env.example content.env
cp repo/deploy/admin.env.example admin.env
cp repo/services/site/.env.example site.env
chmod 600 .env *.env
```
In `.env`, check that `PUID` and `PGID` match `id -u greg` and `id -g greg`, so the content service can write notes back into the vault.
2. **Tokens.** Make one per role with `openssl rand -hex 24`:
- In `content.env`, set `CONTENT_TOKENS=reader:<r>,admin:<a>,intake:<i>,agent:<g>`.
- In `admin.env`, set `ADMIN_TOKEN=<a>`.
- In `site.env`, set `CONTENT_TOKEN=<r>` and `CONTENT_INTAKE_TOKEN=<i>`.
- Give agents `<g>`.
Leave `CONTENT_URL` out of `site.env`; compose sets it.
3. **The rest of the settings.** Fill in Healthchecks, the Discord webhook, Turnstile, SMTP and the sync logins. The list is in `/mnt/project-files/website/phase6/setup-on-cosmos.md` and in each `.env.example`.
4. **First deploy.** Run it by hand once and watch it:
```sh
/home/greg/homelab/gregkilmer/repo/deploy/pull-and-deploy.sh
```
Then check:
- `docker compose -f /home/greg/homelab/gregkilmer/compose.yaml ps` shows three healthy containers.
- `docker logs gk-content` says `Content service on 0.0.0.0:4310: N notes`.
5. **Deploy timer.** This makes every later merge to master deploy on its own:
```sh
sudo cp /home/greg/homelab/gregkilmer/repo/deploy/gk-deploy.{service,timer} /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now gk-deploy.timer
```
- The service runs as `greg`, who must be in the `docker` group.
- Make a Healthchecks check with a 5-minute period, and set its URL as `DEPLOY_HEALTHCHECK_URL` in `.env`. It turns red when GitHub can't be reached or a commit fails to deploy.
- Logs: `journalctl -u gk-deploy`.
6. **Nginx Proxy Manager.** Add a proxy host for each hostname. Force SSL on each. For `content`, add `proxy_buffering off;` under Advanced, because MCP streams its responses.
| Host | Forward to | Access list |
| --- | --- | --- |
| `admin.gregkilmer.com` | `http://gk-admin:4320` | Tailnet only |
| `content.gregkilmer.com` | `http://gk-content:4310` | Tailnet only |
| `beta.gregkilmer.com` | `http://gk-site:4321` | Tailnet only |
- DNS: an A record for each pointing at 100.99.111.16 (TTL 300), the same as other tailnet hosts.
- Certificates: Let's Encrypt can't reach a tailnet-only host over HTTP, so use NPM's DNS challenge with your DNS provider. This is the same open item as the `*.patiun.com` certificate.
- Agents already on homelab_network can skip NPM and use `http://gk-content:4310/mcp`.
7. **Turnstile.** Add `beta.gregkilmer.com` and `gregkilmer.com` to the widget's hostnames.
8. **Healthchecks.** Create these checks:
- one for the content service's full scan (`HEALTHCHECK_URL`), every 5 minutes;
- one per sync job you turn on (Archidekt, Suwayomi, Bambuddy and Homebox hourly; BGG and Kavita daily; covers every 15 minutes).
9. **Inventory note.** Copy `vault-kit/24.01_PersonalWebsite/GregKilmer.com stack.md` into the vault's `24.01_PersonalWebsite` folder, so `cosmos-inventory` stops listing the containers as undocumented.
## Backups
- `gk-content` carries `backup.dump=sqlite`. `cosmos-dump` finds `content.db` in its `/data` mount and copies it with `VACUUM INTO` before Kopia runs.
- `content.db` holds note history, the review queue, saved inquiries and orders, and the sync cache.
- The vault mount has no SQLite files, so nothing else is dumped.
- Form attachments are plain files in `data/content/submissions/`, which Kopia picks up with the rest of `/home/greg`.
- The plan called for a cache folder in `exclusions.conf`. None is needed: the sync cache and search index live inside `content.db` and are small.
- `gk-admin` and `gk-site` keep no data.
## Cutover (phase 8)
1. In NPM, point `gregkilmer.com` and `www.gregkilmer.com` at `http://gk-site:4321` with no access list, and retire the old `greg-profile-site` container.
2. Turn on Cloudflare's proxy for both records with SSL mode Full (strict). Cloudflare doesn't cache HTML by default, which is what the site needs: pages change within a minute of a note changing.
3. The site already trusts `X-Forwarded-Host` and `X-Forwarded-Proto` for gregkilmer.com and its subdomains (`security.allowedDomains` in `services/site/astro.config.mjs`), so form posts pass Astro's origin check behind the proxy. It reads the visitor's address from `CF-Connecting-IP` for the form rate limits.
## Day to day
- **Deploy:** merge to master on GitHub. Cosmos picks it up within 5 minutes. To deploy straight away, run `sudo systemctl start gk-deploy`.
- **Retry a failed commit:** `FORCE=1 /home/greg/homelab/gregkilmer/repo/deploy/pull-and-deploy.sh`.
- **Roll back:** `/home/greg/homelab/gregkilmer/repo/deploy/rollback.sh`, or set `GK_TAG` in `.env` to any image tag still on the host and run `docker compose up -d`.
- **Logs:** `docker logs gk-content`. Sync runs also appear on the admin page's Overview.
- **Restart one service:** `docker compose -f /home/greg/homelab/gregkilmer/compose.yaml restart gk-site`.
+162
View File
@@ -0,0 +1,162 @@
# The site
`services/site` (`@gk/site`) is the public GregKilmer.com. It is an Astro app
rendered on request by the Node adapter. It reads only the content service,
with a reader token, and only published notes.
## How a page gets its data
```astro
---
import PageShell from "@gk/ui/components/PageShell.astro";
import { site } from "../lib/page.ts";
const s = await site(); // the SiteIndex: every published note in memory
const games = s.all("board-game"); // typed: games[0].data.bggId is a number
---
```
`src/lib/content.ts` loads every published note from the content service and
refreshes in the background every 30 seconds (`CONTENT_REFRESH_SECONDS`). If
the service is down, the last good copy keeps serving.
`src/lib/site.ts` (`SiteIndex`) answers everything a page asks:
| Method | Gives |
| --- | --- |
| `all(type)`, `one(type)`, `bySlug(type, slug)`, `byName(name or [[link]])` | Notes. |
| `href(note)` | The address that shows a note (see the URL map below). |
| `link("[[Name]]")` | `{ title, href, note }`; `href` is null when the note is not published, so render plain text. |
| `backlinks(note, { type })` | Notes linking to it, newest first. Links to a make count for its craft, a deck for its game, a campaign for its system. |
| `linksFrom(note)` | Notes it links to. |
| `posts({ topic })`, `caseStudies()` | Blog posts and case studies, newest first. |
| `statuses()`, `statusFor(note)` | The status log (newest per subject, pinned first) and a note's current status. |
| `kind(label)` | A status kind's brand icon and page. |
| `pageText(path)` | The Page text note for an address: lede, body, quick facts. |
| `settings()` | Site settings: name, tagline, availability, socials. |
| `render(markdown)` | Safe HTML with wikilinks resolved. |
| `redirect(type, slug)` | Where an old slug now lives; answer it with a 301. |
`mediaSrc(value)` turns an image field into a `/media/<file>` address, which
passes vault images through from the content service. `formatDate("2026-10-11")`
gives "Oct 11, 2026".
## URL map
| Address | Shows |
| --- | --- |
| `/` | Landing |
| `/about` | About me |
| `/resume`, `/resume.pdf` | Resume and its PDF |
| `/work`, `/work/<slug>` | Case studies and each study |
| `/inquiries`, `/inquiries/sent` | Inquiry form and its confirmation |
| `/library`, `/library/shelf` | The Library and The Shelf; items are anchors `#<slug>` |
| `/workshop`, `/workshop/<craft>` | Workshop and each craft; makes are anchors on their craft |
| `/tabletop` | Systems, campaigns, card games and decks as anchors |
| `/now` | Every status; each is an anchor |
| `/blog`, `/blog/topic/<topic>`, `/blog/<slug>`, `/rss.xml` | Blog, topics, posts, feed |
| `/store`, `/store/<line>`, `/store/order`, `/store/order/sent` | Store, product lines, order form and its confirmation |
| anything else | 404, Off the star chart |
## Rules for pages
- Build from the components in `@gk/ui` (see [components.md](components.md)) and
the gk-* classes. Every page uses `PageShell` with its `family` (edge seal)
and `current` (nav entry); its hero goes in the `hero` slot.
- All dark. Panels are Midnight with a gold frame, not Starlight.
- Never invent facts or numbers. Where a note has no value, leave the element
out, or show `[PLACEHOLDER]` where the design needs text Greg has not
written yet.
- No phone numbers. Email addresses stay as they are in the notes.
- Lists of posts are fixed-height rows, not stretching cards.
- Small client scripts (filters, tabs) are fine; the page must read correctly
without them.
## Running it locally
```sh
# the content service, against a copy of the Site folder
SITE_DIR=… DATA_DIR=… CONTENT_TOKENS="reader:<24+ chars>" npm run content
# the site
CONTENT_URL=http://localhost:4310 CONTENT_TOKEN=<reader token> npm run site # http://localhost:4330
```
For a local preview of drafts, set `CONTENT_INCLUDE_DRAFTS=true` with an editor
or admin token. Never set it where the public can reach the site.
## The resume PDF
`/resume.pdf` is drawn with pdfkit from the same `resumeData()` as `/resume`
(`src/lib/resume.ts`), so the two never disagree. Each page is laid over the
personal Greg Kilmer letterhead, the version without Patiun LLC
(`src/assets/letterhead`, a copy of `Greg-Kilmer-Personal-Letterhead.pdf` from the
branding letterheads; re-copy it when that letterhead changes) with pdf-lib, which keeps both as vectors. It reads top to bottom so
applicant tracking systems take it in order, aims for one page (roles marked
`earlier` print as a short list; skills share a row with education and
projects), and when a one-page resume ends well above the footer it is laid
out again with wider gaps (up to twice as wide) so the page reads as full. It uses the brand TTFs in
`src/assets/pdf-fonts`, and is rebuilt only when the notes behind it change.
The highlights row and Selected projects come from the `/resume` Page text note's
`stats` and `projects`. Greg's Resume Interview answers live in
`tools/migrate/src/seed.ts`.
## Where each page lives
| Page | File | Helpers |
| --- | --- | --- |
| Landing, About, Now | `pages/index.astro`, `about.astro`, `now.astro` | `lib/home.ts`, `components/home` |
| Resume and PDF | `pages/resume.astro`, `resume.pdf.ts` | `lib/resume.ts`, `lib/resume-pdf.ts` |
| Work, case studies | `pages/work/` | `lib/work.ts`, `components/work`, `components/blog/PostLayout` |
| Inquiries, sent, 404 | `pages/inquiries/`, `404.astro` | `lib/work.ts` |
| Library, Shelf | `pages/library/` | `lib/library.ts`, `components/library` |
| Workshop, crafts, Tabletop | `pages/workshop/`, `tabletop.astro` | `lib/world.ts`, `components/world` |
| Blog, topics, posts, RSS | `pages/blog/`, `rss.xml.ts` | `lib/blog.ts`, `components/blog` |
| Store, lines, order | `pages/store/` | `lib/store.ts`, `components/store` |
## Filters on world pages
- **Shelf**: How it plays, Players, Play time, Owned, Want to play, Favorites
and Has a blog post, plus four sorts. Players and Play time read the
BoardGameGeek sync and only appear once some game has those values. With no
filter narrowing the list, the first 12 games show and "Show all" opens the
rest.
- **Craft pages**: when a craft's makes carry two or more `tags` (Dice, Rings,
and so on; `sword rack` does not count), pills above the grid filter them.
Makes without a tag fall under Other.
## Forms
The Inquiries form posts to `POST /api/inquiries` and the order form to
`POST /api/orders` (`src/pages/api/`). Both are plain multipart posts that
answer with a 303 redirect, so they work without JavaScript. The rules are
plain functions in `src/lib/forms.ts` (tested in `services/site/test`); the
network calls are in `src/lib/forms-server.ts`.
1. A filled honeypot field (`leave_blank`) gets the confirmation page and nothing else.
2. The answers are checked. Inquiries take only the chosen type's `job_*`,
`freelance_*` or `patiun_*` fields; others are ignored.
3. A per-address rate limit, then Cloudflare Turnstile when
`TURNSTILE_SECRET_KEY` is set (a missing or bad token, or Cloudflare being
unreachable, turns the post down).
4. Orders only: the line is checked against the live store notes, fetched
from the content service then and there. An order goes through only for a
line sold here, in a category with `ordersOpen: true`, with an open slot
(made to order) or stock, and with in-stock options. Otherwise it is
turned down, whatever was posted.
5. The copy is saved through the content service with `CONTENT_INTAKE_TOKEN`,
then emailed (SMTP via nodemailer) to `CONTACT_TO` or `ORDER_TO`, and the
email's result is recorded on the copy. A failed email still shows the
visitor the confirmation; the admin Inbox flags it. If saving fails the
email is still tried; only when neither works does the form show an error.
6. Success redirects to `/inquiries/sent` (cookie `gk-inquiry`) or
`/store/order/sent` (cookie `gk-order`): URL-encoded JSON, httpOnly,
SameSite Lax, ten minutes, secure in production. A turned-down post goes
back to the form with `?error=<code>` and a `gk-inquiry-draft` or
`gk-order-draft` cookie holding the reason, the marked fields and the
answers (an attachment has to be added again).
Settings are in `services/site/.env.example`: `CONTENT_INTAKE_TOKEN`,
`PUBLIC_TURNSTILE_SITE_KEY`, `TURNSTILE_SECRET_KEY`, `SMTP_HOST`, `SMTP_PORT`,
`SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_ALLOW_SELFSIGNED`, `CONTACT_TO`,
`ORDER_TO`, `CONTACT_RATE_LIMIT`, `CONTACT_RATE_WINDOW_MIN`, `ORDER_RATE_LIMIT`,
`ORDER_RATE_WINDOW_MIN`, `FORM_MAX_ATTACHMENT_MB`. The Turnstile widget needs
JavaScript; with the secret set, a visitor without it cannot send a form.
+17
View File
@@ -0,0 +1,17 @@
# Keep the Docker build context lean.
node_modules
dist
.astro
.git
.gitignore
.env
.env.*
!.env.example
*.log
npm-debug.log*
.DS_Store
.vscode
.idea
docs
*.md
!README.md
+30
View File
@@ -0,0 +1,30 @@
# build output
dist/
.output/
# dependencies
node_modules/
# generated types
.astro/
# environment
.env
.env.*
!.env.example
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# macOS / editor
.DS_Store
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
.idea/
# misc
*.local
+53
View File
@@ -0,0 +1,53 @@
# =============================================================================
# Dockerfile — Greg Kilmer Profile Site
# =============================================================================
# Multi-stage build producing a minimal Node server image for the Cosmos homelab.
#
# Stage 1 (builder): install all deps, run astro build -> dist/server/entry.mjs
# Stage 2 (runtime): copy only dist + prod node_modules, run the Node server
#
# The @astrojs/node standalone adapter emits a self-contained server, so the
# runtime image needs only node_modules + dist. Build once, run anywhere.
# =============================================================================
# ---------- Stage 1: build ----------
FROM node:22.12.0-alpine AS builder
WORKDIR /app
# Install deps. Copy manifests first to leverage Docker layer caching.
COPY package.json package-lock.json* ./
RUN npm ci --no-audit --no-fund || npm install --no-audit --no-fund --legacy-peer-deps
# Copy source and build. SITE_URL can be overridden at build time.
COPY . .
ARG SITE_URL=https://gregkilmer.com
ENV SITE_URL=$SITE_URL
RUN npm run build
# Prune to production dependencies for the runtime stage.
RUN npm prune --omit=dev
# ---------- Stage 2: runtime ----------
FROM node:20-alpine AS runtime
WORKDIR /app
# Run as a non-root user for defense in depth.
RUN addgroup -S app && adduser -S app -G app
# Copy production node_modules and the built server + client assets.
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./package.json
# Astro Node standalone server reads HOST/PORT from env at runtime.
ENV HOST=0.0.0.0
ENV PORT=4321
EXPOSE 4321
USER app
# Healthcheck: the server responds 200 on /.
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- http://127.0.0.1:${PORT}/ >/dev/null 2>&1 || exit 1
CMD ["node", "./dist/server/entry.mjs"]
+7
View File
@@ -0,0 +1,7 @@
# Legacy site
The GregKilmer.com site as it was before the 2026 rebuild, kept for reference.
It still runs the live site until cutover, built from this folder.
Do not add features here. Its data was migrated into vault notes by
`tools/migrate`; new content goes into the vault.
+156
View File
@@ -0,0 +1,156 @@
# Greg Kilmer — Profile & Portfolio Site
A responsive, content-driven personal site for Greg Kilmer, built as **two wings
of the same house**: a polished professional resume/portfolio on one side, and a
warm, personality-driven interests hub on the other.
Built with **Astro 7 + React Islands + TypeScript**, served by a **Node server**
with API routes, ready to deploy as a container on a homelab Cosmos system.
---
## Highlights
- **Astro 7** static-first core with React Islands for interactivity (mobile
menu, theme toggle, animated hero background, contact form).
- **Node backend** (`@astrojs/node`, standalone) with a typed `/api/contact`
endpoint (SMTP via nodemailer + webhook fallback, Zod validation, rate limit).
- **Section-aware theming**: the Professional wing reads dark/polished, the
Interests wing reads warm/inviting, plus a global light/dark toggle.
- **Rich motion**: animated mesh-gradient hero with an optional canvas particle
field, IntersectionObserver scroll reveals, View Transitions page swaps,
scroll-triggered nav blur. All `prefers-reduced-motion` aware.
- **Content Collections** (typed): add projects and experience by dropping
markdown/MDX files.
- **mdi icons everywhere** (no emojis, per the spec), inlined at build time from
`@iconify-json/mdi` for zero runtime cost.
- **Self-hosted variable fonts** (Inter, Plus Jakarta Sans, JetBrains Mono).
- **Accessible**: semantic landmarks, skip link, keyboard-operable menu,
focus-visible rings, ARIA on interactive bits, WCAG-AA contrast targets.
- **Deploy artifacts**: multi-stage `Dockerfile` + `docker-compose.yml`.
---
## Quickstart
```bash
# 1. Install dependencies (Node 20+)
npm install
# 2. Start the dev server with hot reload
npm run dev
# -> http://localhost:4321
# 3. Production build (runs astro check + build)
npm run build
# 4. Run the built Node server
npm run serve
# -> http://localhost:4321
```
Environment is optional for local dev. To enable contact-form delivery, copy
`.env.example` to `.env` and fill in SMTP or webhook vars (see
[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)).
---
## Project structure
```
src/
├── components/
│ ├── hero/ AnimatedBackground.tsx (gradient + canvas particles)
│ ├── motion/ Reveal.astro (IntersectionObserver scroll reveals)
│ ├── nav/ NavBar.astro, MobileMenu.tsx, ThemeToggle.tsx
│ ├── cards/ InterestCard.astro
│ ├── professional/ Timeline, SkillGrid, ProjectCard, SectionNav, ContactForm
│ ├── layout/ Footer, SectionHeader, SplitTransition, InterestHero,
│ │ RelatedInterests, Placeholder
│ └── Icon.astro Inline mdi icon renderer (build-time)
├── content/
│ ├── projects/ Project collection entries (markdown/MDX)
│ └── experience/ Experience collection entries
├── layouts/
│ └── BaseLayout.astro <html> shell, head, fonts, theme init, nav, footer
├── pages/
│ ├── index.astro Home (hero + snapshot grid)
│ ├── about.astro About + split transition fork
│ ├── professional/index.astro Single long page: Summary, Experience,
│ │ Skills, Projects, Education, Contact
│ ├── interests/ Hub + 5 sub-pages
│ │ ├── index.astro
│ │ ├── ttrpgs / gaming / reading / making / pokemon .astro
│ ├── contact.astro Two-column connect page + contact form
│ └── api/contact.ts POST endpoint -> SMTP / webhook
├── content.config.ts Collection schemas (projects, experience)
├── lib/ site.ts (nav/socials), motion.ts, content helpers
├── styles/ global.css, themes.css, animations.css
└── scripts/theme-init.ts No-FOUC theme boot (inlined in <head>)
```
---
## The two wings (theming model)
Theming has two independent axes (see [docs/THEMING.md](docs/THEMING.md)):
1. **Global light/dark** on `<html data-theme>`, set before paint by an inline
script, persisted to `localStorage`, toggleable via the nav button.
2. **Section tone** via `data-surface` on `<body>`:
- `neutral` (Home/About/Contact) — balanced middle ground
- `professional` — dark slate, polished
- `interest` — warm cream/amber, inviting
Components only reference semantic CSS variables (`--surface`, `--text`,
`--accent`...), so changing either axis restyles everything automatically.
---
## Adding content
- **Projects / Experience:** drop a markdown or MDX file in
`src/content/projects/` or `src/content/experience/`. Schemas and field
reference are in [docs/CONTENT.md](docs/CONTENT.md).
- **Interest pages:** hand-built `.astro` files under `src/pages/interests/`,
each following the spec's template (Header, Overview, Highlights, Currently,
Related). Edit copy in place.
- **Placeholders:** every `[Greg to fill]` gap from the spec is a styled
`Placeholder` slot with a TODO badge so you can find and replace them.
---
## Deployment (Cosmos homelab)
The site runs as a Node server inside a container. Full walkthrough in
[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md):
```bash
docker compose up -d --build
# Then point your reverse proxy at port 4321 with TLS for gregkilmer.com.
```
---
## Scripts
| Script | Description |
| ----------------- | ----------------------------------------------------- |
| `npm run dev` | Dev server with hot reload |
| `npm run build` | Type-check (`astro check`) then build the Node server |
| `npm run serve` | Run the built server (`dist/server/entry.mjs`) |
| `npm run preview` | Preview the build locally |
| `npm run check` | Type-check only |
---
## Tech stack
Astro 7, React 19, TypeScript 6, Tailwind CSS v4, Framer Motion, nodemailer,
Zod, `@iconify-json/mdi`, Fontsource variable fonts, `@astrojs/node` standalone.
---
## License
Personal site for Greg Kilmer. All rights reserved.
+45 -10
View File
@@ -6,16 +6,27 @@ How color, light/dark mode, section tone, and animation work on this site.
## Palette ## Palette
From the spec, exposed as CSS variables in `src/styles/themes.css` and mapped to The site uses the Patiun LLC brand palette (shared by Patiun and Greg Kilmer),
Tailwind utilities in `src/styles/global.css`: exposed as CSS variables in `src/styles/global.css` under `@theme`:
| Token | Value | Meaning | | Token | Value | Meaning |
|---|---|---| |---|---|---|
| `--color-slate` | `#1E293B` | Professional confidence (primary) | | `--color-teal` | `#14A39A` | Patiun Teal, primary: buttons, fills, the glyph |
| `--color-amber` | `#F59E0B` | Warm accent (the making/creative side) | | `--color-teal-bright` | `#2BC2B7` | Teal text and links on dark grounds |
| `--color-teal` | `#699a94` | Soft teal (secondary, favorite) | | `--color-teal-deep` | `#0F8C84` | Teal marks on light grounds |
| `--color-ink` | `#0F172A` | Near-black text on light | | `--color-slate-teal` | `#2D4A58` | Dark end of the Tide gradient |
| `--color-cream` | `#FFFBEB` | Cream-white text on dark | | `--color-gold` | `#C9A24A` | Alchemist Gold: stars, rules, seal lines (never a large fill) |
| `--color-gold-bright` | `#D8B35A` | Gold text on dark grounds |
| `--color-gold-deep` | `#8A6516` | Gold text under 24px on Starlight |
| `--color-violet` / `--color-indigo` | `#6A2FE0` / `#3B1F8F` | Nebula light only, never text |
| `--color-midnight` | `#131C2B` | Panels, tiles, browser theme color |
| `--color-sky` | `#0E1522` | Night-sky ground |
| `--color-starlight` | `#F4F2ED` | Text on dark; paper ground |
The semantic Tailwind tokens (`--color-accent`, `--color-surface`, ...) resolve
at `:root`, so content sections render with the Starlight values in
`themes.css`. Night-sky bands (the nav and page heroes) carry the `.on-sky`
class, which re-points accent and gold to their on-dark values.
Brand colors are fixed. **Semantic** tokens (`--surface`, `--text`, `--accent`, Brand colors are fixed. **Semantic** tokens (`--surface`, `--text`, `--accent`,
`--secondary`, `--border`, ...) resolve differently per theme + section, and are `--secondary`, `--border`, ...) resolve differently per theme + section, and are
@@ -118,9 +129,33 @@ reduce)')` before drawing, so even JS-driven motion is skipped.
Self-hosted variable fonts via Fontsource (woff2, no external requests): Self-hosted variable fonts via Fontsource (woff2, no external requests):
- **Headings / UI:** Plus Jakarta Sans Variable (geometric sans) - **Headings, names, card titles:** Cinzel Variable (`font-display`, used by
- **Body:** Inter Variable `h1`-`h4` automatically). Open tracking, never for paragraphs.
- **Mono / kickers / quotes:** JetBrains Mono Variable - **Body and UI:** Montserrat Variable (`font-sans`, `font-body`).
- **Eyebrows / kickers:** Montserrat uppercase with wide tracking (`font-eyebrow`).
- **Code-like blocks only:** JetBrains Mono Variable (`font-mono`).
Font sizes use `clamp()` for fluid scaling (see the `h1`-`h4` rules in Font sizes use `clamp()` for fluid scaling (see the `h1`-`h4` rules in
`global.css`). `global.css`).
---
## Icons and motifs
- **Icons:** `<Icon name="..." />` (Astro) and `<BrandIcon name="..." />` (React
islands) render the Patiun stroke icon set in `src/lib/brand-icons.ts`. Call
sites can use either the brand name (`dice`) or the mdi name (`dice-multiple`);
`brandIconAliases` maps one to the other. Only third-party logos (GitHub,
LinkedIn, Steam, ...) fall back to mdi. The gold star accent uses
`--icon-gold` (deep gold on Starlight, flat gold inside `.on-sky`). The file is
generated from the brand source (`newicons.py`); add icons there.
- **Motifs:** `<Motif name="..." />` shows a brand motif from `public/motifs/`,
copied from the brand files with ring text outlined to paths. `-light` files
are the Starlight recolor. One focal motif per surface, kept faint. Every
page carries at least one seal: night-sky heroes have the turning layered
seal, and pages without a hero use `<PageSeal name="..." />` behind the header.
- **Shape:** cards and panels have 10px corners (`--radius-xl`/`2xl`/`3xl` are
pinned to 10px); buttons and tags are pills. The Patiun seal is never shown
below 72px (nav and footer use 72px).
- **Gold accents:** image frames, quote bars, dividers and rules use
`--color-gold`; teal stays for links, buttons and icons.
+8470
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
{
"name": "greg-kilmer-profile",
"version": "1.0.0",
"type": "module",
"private": true,
"description": "Personal profile / portfolio website for Greg Kilmer. Astro + React Islands + Node server.",
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro check && astro build",
"preview": "astro preview",
"serve": "node ./dist/server/entry.mjs",
"astro": "astro",
"check": "astro check",
"typecheck": "astro check && tsc --noEmit"
},
"dependencies": {
"@astrojs/check": "^0.9.9",
"@astrojs/mdx": "^7.0.2",
"@astrojs/node": "^11.0.2",
"@astrojs/react": "^6.0.2",
"@fontsource-variable/cinzel": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fontsource-variable/montserrat": "^5.3.0",
"@iconify-json/mdi": "^1.2.3",
"@tailwindcss/vite": "^4.3.2",
"@types/nodemailer": "^8.0.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"astro": "^7.2.0",
"framer-motion": "^12.42.2",
"nodemailer": "^9.0.3",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"tailwindcss": "^4.3.2",
"typescript": "^6.0.3",
"zod": "^4.4.3"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+76
View File
@@ -0,0 +1,76 @@
<svg xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Patiun LLC seal" viewBox="6 6 180 181"><g transform="translate(102,96.5) scale(0.82) translate(-96,-96.5)"><g fill="none" stroke="#14A39A" stroke-width="7.2" stroke-linejoin="round">
<path d="M31,46.75 A54.3,54.3 0 1 1 31,146.25 A53.4,53.4 0 0 0 31,46.75 Z"/><circle cx="134" cy="96.5" r="26.9"/></g><circle cx="134" cy="96.5" r="10.3" fill="#14A39A"/></g>
<circle cx="96" cy="96.5" r="84" fill="none" stroke="#C9A24A" stroke-width="1.8"/>
<circle cx="96" cy="96.5" r="76" fill="none" stroke="#C9A24A" stroke-width="1"/>
<line x1="171.71" y1="103.12" x2="174.90" y2="103.40" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="170.85" y1="109.70" x2="174.00" y2="110.25" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="169.41" y1="116.17" x2="172.50" y2="117.00" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="167.42" y1="122.49" x2="170.42" y2="123.59" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="164.88" y1="128.62" x2="167.78" y2="129.97" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="161.82" y1="134.50" x2="168.75" y2="138.50" stroke="#C9A24A" stroke-width="1.3"/>
<line x1="158.26" y1="140.09" x2="160.88" y2="141.93" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="154.22" y1="145.35" x2="156.67" y2="147.41" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="149.74" y1="150.24" x2="152.00" y2="152.50" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="144.85" y1="154.72" x2="146.91" y2="157.17" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="139.59" y1="158.76" x2="141.43" y2="161.38" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="134.00" y1="162.32" x2="138.00" y2="169.25" stroke="#C9A24A" stroke-width="1.3"/>
<line x1="128.12" y1="165.38" x2="129.47" y2="168.28" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="121.99" y1="167.92" x2="123.09" y2="170.92" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="115.67" y1="169.91" x2="116.50" y2="173.00" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="109.20" y1="171.35" x2="109.75" y2="174.50" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="102.62" y1="172.21" x2="102.90" y2="175.40" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="89.38" y1="172.21" x2="89.10" y2="175.40" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="82.80" y1="171.35" x2="82.25" y2="174.50" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="76.33" y1="169.91" x2="75.50" y2="173.00" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="70.01" y1="167.92" x2="68.91" y2="170.92" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="63.88" y1="165.38" x2="62.53" y2="168.28" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="58.00" y1="162.32" x2="54.00" y2="169.25" stroke="#C9A24A" stroke-width="1.3"/>
<line x1="52.41" y1="158.76" x2="50.57" y2="161.38" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="47.15" y1="154.72" x2="45.09" y2="157.17" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="42.26" y1="150.24" x2="40.00" y2="152.50" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="37.78" y1="145.35" x2="35.33" y2="147.41" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="33.74" y1="140.09" x2="31.12" y2="141.93" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="30.18" y1="134.50" x2="23.25" y2="138.50" stroke="#C9A24A" stroke-width="1.3"/>
<line x1="27.12" y1="128.62" x2="24.22" y2="129.97" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="24.58" y1="122.49" x2="21.58" y2="123.59" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="22.59" y1="116.17" x2="19.50" y2="117.00" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="21.15" y1="109.70" x2="18.00" y2="110.25" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="20.29" y1="103.12" x2="17.10" y2="103.40" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="20.29" y1="89.88" x2="17.10" y2="89.60" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="21.15" y1="83.30" x2="18.00" y2="82.75" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="22.59" y1="76.83" x2="19.50" y2="76.00" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="24.58" y1="70.51" x2="21.58" y2="69.41" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="27.12" y1="64.38" x2="24.22" y2="63.03" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="30.18" y1="58.50" x2="23.25" y2="54.50" stroke="#C9A24A" stroke-width="1.3"/>
<line x1="33.74" y1="52.91" x2="31.12" y2="51.07" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="37.78" y1="47.65" x2="35.33" y2="45.59" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="42.26" y1="42.76" x2="40.00" y2="40.50" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="47.15" y1="38.28" x2="45.09" y2="35.83" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="52.41" y1="34.24" x2="50.57" y2="31.62" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="58.00" y1="30.68" x2="54.00" y2="23.75" stroke="#C9A24A" stroke-width="1.3"/>
<line x1="63.88" y1="27.62" x2="62.53" y2="24.72" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="70.01" y1="25.08" x2="68.91" y2="22.08" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="76.33" y1="23.09" x2="75.50" y2="20.00" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="82.80" y1="21.65" x2="82.25" y2="18.50" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="89.38" y1="20.79" x2="89.10" y2="17.60" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="102.62" y1="20.79" x2="102.90" y2="17.60" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="109.20" y1="21.65" x2="109.75" y2="18.50" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="115.67" y1="23.09" x2="116.50" y2="20.00" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="121.99" y1="25.08" x2="123.09" y2="22.08" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="128.12" y1="27.62" x2="129.47" y2="24.72" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="134.00" y1="30.68" x2="138.00" y2="23.75" stroke="#C9A24A" stroke-width="1.3"/>
<line x1="139.59" y1="34.24" x2="141.43" y2="31.62" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="144.85" y1="38.28" x2="146.91" y2="35.83" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="149.74" y1="42.76" x2="152.00" y2="40.50" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="154.22" y1="47.65" x2="156.67" y2="45.59" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="158.26" y1="52.91" x2="160.88" y2="51.07" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="161.82" y1="58.50" x2="168.75" y2="54.50" stroke="#C9A24A" stroke-width="1.3"/>
<line x1="164.88" y1="64.38" x2="167.78" y2="63.03" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="167.42" y1="70.51" x2="170.42" y2="69.41" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="169.41" y1="76.83" x2="172.50" y2="76.00" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="170.85" y1="83.30" x2="174.00" y2="82.75" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="171.71" y1="89.88" x2="174.90" y2="89.60" stroke="#C9A24A" stroke-width="0.8"/>
<polygon points="96.00,7.00 97.88,14.62 105.50,16.50 97.88,18.38 96.00,26.00 94.12,18.38 86.50,16.50 94.12,14.62" fill="#C9A24A"/>
<polygon points="176.00,87.00 177.88,94.62 185.50,96.50 177.88,98.38 176.00,106.00 174.12,98.38 166.50,96.50 174.12,94.62" fill="#C9A24A"/>
<polygon points="96.00,167.00 97.88,174.62 105.50,176.50 97.88,178.38 96.00,186.00 94.12,178.38 86.50,176.50 94.12,174.62" fill="#C9A24A"/>
<polygon points="16.00,87.00 17.88,94.62 25.50,96.50 17.88,98.38 16.00,106.00 14.12,98.38 6.50,96.50 14.12,94.62" fill="#C9A24A"/></svg>

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><defs><linearGradient id="fgk" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#131C2B"/><stop offset="1" stop-color="#1F3B49"/></linearGradient></defs><rect width="64" height="64" rx="14" fill="url(#fgk)"/><circle cx="32" cy="32" r="26" fill="none" stroke="#14A39A" stroke-width="2.4" /><g fill="#F4F2ED" aria-label="GK"><path d="M754.5337524414062 280V115Q730.9266357421875 85 683.427001953125 55.0Q635.9273681640625 25 569.7135009765625 5.5Q503.4996337890625 -14 422.6427001953125 -14Q309.3572998046875 -13.60711669921875 224.41104125976562 30.67864990234375Q139.46478271484375 74.96441650390625 92.1434326171875 156.76797485351562Q44.82208251953125 238.571533203125 44.82208251953125 351.2142333984375Q44.82208251953125 462.6427001953125 91.53631591796875 544.0355834960938Q138.25054931640625 625.428466796875 224.714599609375 669.7142333984375Q311.17864990234375 714 428.7138671875 714Q483.7138671875 714 535.9099426269531 706.5177917480469Q588.1060180664062 699.0355834960938 632.1056518554688 686.8747253417969Q676.1052856445312 674.7138671875 705.1052856445312 659.3565673828125L706.8917846679688 488.000732421875H697.8917846679688Q679.2490844726562 556.3217163085938 643.2312927246094 596.4288330078125Q607.2135009765625 636.5359497070312 559.7668762207031 654.1968078613281Q512.3202514648438 671.857666015625 459.0697021484375 671.857666015625Q377.4981689453125 671.857666015625 321.39068603515625 632.2861328125Q265.283203125 592.714599609375 236.568603515625 519.8395080566406Q207.85400390625 446.96441650390625 207.85400390625 345.14306640625Q207.85400390625 248.7857666015625 234.44369506835938 177.428466796875Q261.03338623046875 106.0711669921875 311.9803771972656 67.30319213867188Q362.9273681640625 28.53521728515625 435.999267578125 28.142333984375Q473.32098388671875 28.142333984375 507.4462585449219 39.553009033203125Q541.571533203125 50.96368408203125 567.5541076660156 73.19570922851562Q593.5366821289062 95.427734375 604.5374145507812 127.28466796875L605.5374145507812 280Q605.5374145507812 325 554.5374145507812 325H538.5374145507812V335H819.5337524414062V325H804.5337524414062Q752.5337524414062 325 754.5337524414062 280Z" transform="translate(12.09,41.50) scale(0.02500,-0.02500)"/><path d="M412.851806640625 430.53375244140625 674.99560546875 72.24908447265625Q697.8169555664062 41.9281005859375 722.0489807128906 26.46405029296875Q746.281005859375 11 777.281005859375 10V0H694.8184204101562Q684.2113037109375 0 658.7294616699219 1.196441650390625Q633.2476196289062 2.39288330078125 601.1230773925781 9.410675048828125Q568.99853515625 16.428466796875 536.5704345703125 33.696075439453125Q504.142333984375 50.96368408203125 479.85693359375 83.10601806640625Q475 89.7843017578125 460.2320251464844 109.87362670898438Q445.46405029296875 129.96295166015625 425.24945068359375 157.80245971679688Q405.03485107421875 185.6419677734375 383.3380432128906 215.55300903320312Q361.6412353515625 245.46405029296875 343.04561360677087 271.22924296061194Q324.4499918619792 296.9944356282552 312.61641438802087 313.3904673258463Q300.7828369140625 329.7864990234375 300.7828369140625 329.7864990234375ZM663.6034545898438 693 362.7821044921875 335.857666015625 300.7828369140625 329.7864990234375 575.7828369140625 693ZM733.8176879882812 700V690.8438842773437H729.8176879882812Q696.8176879882812 690.8438842773437 668.3176879882812 677.6183837890625Q639.8176879882812 664.3928833007812 619.8176879882812 642L538.6041870117188 644Q550.9970703125 661 543.1041870117188 676.0Q535.2113037109375 691 514.8065592447916 691H504.60418701171875V700H729.8176879882812ZM268.7828369140625 700V0H118.7864990234375V700ZM121.7864990234375 73V0H41.7864990234375V10Q43.000732421875 10 48.590057373046875 10.0Q54.17938232421875 10 54.7864990234375 10Q80.7864990234375 10 99.2864990234375 28.5Q117.7864990234375 47 118.7864990234375 73ZM121.7864990234375 627H118.7864990234375Q117.7864990234375 653 99.2864990234375 671.5Q80.7864990234375 690 54.7864990234375 690Q54.17938232421875 690 48.590057373046875 690.0Q43.000732421875 690 41.7864990234375 690V700H121.7864990234375ZM265.7828369140625 73H268.7828369140625Q269.7828369140625 47 288.2828369140625 28.5Q306.7828369140625 10 332.7828369140625 10Q333.9970703125 10 339.43665489783655 10.0Q344.8762394831731 10 345.7828369140625 10V0H265.7828369140625ZM265.7828369140625 627V700H345.7828369140625V690Q344.7828369140625 690 339.38995361328125 690.0Q333.9970703125 690 332.7828369140625 690Q306.7828369140625 690 288.2828369140625 671.5Q269.7828369140625 653 268.7828369140625 627Z" transform="translate(32.99,41.50) scale(0.02500,-0.02500)"/></g><circle cx="32" cy="6" r="4" fill="#131C2B"/><polygon points="32.00,0.80 33.10,4.90 37.20,6.00 33.10,7.10 32.00,11.20 30.90,7.10 26.80,6.00 30.90,4.90" fill="#C9A24A"/></svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Before

Width:  |  Height:  |  Size: 434 KiB

After

Width:  |  Height:  |  Size: 434 KiB

Before

Width:  |  Height:  |  Size: 874 KiB

After

Width:  |  Height:  |  Size: 874 KiB

Before

Width:  |  Height:  |  Size: 176 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Before

Width:  |  Height:  |  Size: 516 KiB

After

Width:  |  Height:  |  Size: 516 KiB

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Before

Width:  |  Height:  |  Size: 669 KiB

After

Width:  |  Height:  |  Size: 669 KiB

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Before

Width:  |  Height:  |  Size: 225 KiB

After

Width:  |  Height:  |  Size: 225 KiB

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Before

Width:  |  Height:  |  Size: 171 KiB

After

Width:  |  Height:  |  Size: 171 KiB

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Before

Width:  |  Height:  |  Size: 512 KiB

After

Width:  |  Height:  |  Size: 512 KiB

Before

Width:  |  Height:  |  Size: 615 KiB

After

Width:  |  Height:  |  Size: 615 KiB

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Before

Width:  |  Height:  |  Size: 207 KiB

After

Width:  |  Height:  |  Size: 207 KiB

Before

Width:  |  Height:  |  Size: 290 KiB

After

Width:  |  Height:  |  Size: 290 KiB

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before

Width:  |  Height:  |  Size: 204 KiB

After

Width:  |  Height:  |  Size: 204 KiB

Before

Width:  |  Height:  |  Size: 277 KiB

After

Width:  |  Height:  |  Size: 277 KiB

Before

Width:  |  Height:  |  Size: 937 KiB

After

Width:  |  Height:  |  Size: 937 KiB

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Before

Width:  |  Height:  |  Size: 401 KiB

After

Width:  |  Height:  |  Size: 401 KiB

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 172 KiB

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 169 KiB

Before

Width:  |  Height:  |  Size: 211 KiB

After

Width:  |  Height:  |  Size: 211 KiB

Before

Width:  |  Height:  |  Size: 202 KiB

After

Width:  |  Height:  |  Size: 202 KiB

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before

Width:  |  Height:  |  Size: 658 KiB

After

Width:  |  Height:  |  Size: 658 KiB

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Before

Width:  |  Height:  |  Size: 190 KiB

After

Width:  |  Height:  |  Size: 190 KiB

Before

Width:  |  Height:  |  Size: 248 KiB

After

Width:  |  Height:  |  Size: 248 KiB

Before

Width:  |  Height:  |  Size: 625 KiB

After

Width:  |  Height:  |  Size: 625 KiB

Before

Width:  |  Height:  |  Size: 874 KiB

After

Width:  |  Height:  |  Size: 874 KiB

Before

Width:  |  Height:  |  Size: 315 KiB

After

Width:  |  Height:  |  Size: 315 KiB

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 145 KiB

Before

Width:  |  Height:  |  Size: 665 KiB

After

Width:  |  Height:  |  Size: 665 KiB

Before

Width:  |  Height:  |  Size: 262 KiB

After

Width:  |  Height:  |  Size: 262 KiB

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Some files were not shown because too many files have changed in this diff Show More