Tyler Geddes
GTM Enablement + Customer Education operator with builder instincts.
Bringing my ideas to life with Claude Code. These aren't prompt-and-pray demos: I design the architecture, understand every moving part, and build tools I actually use.
Working Apps
-
KidPlan

Mobile-first summer activity planner for two boys and a busy family. Integrates tightly with Google Calendar, and manages naps, backup activities when plans abruptly change, and prompts parents to plan out unscheduled days in advance.
Google Apps Script Google Sheets HTML/JS
flowchart TD fe[web/index.html single-file frontend served by Apps Script] fe -->|POST action + token| entry[api.gs doGet / doPost] entry --> dispatch[api.gs dispatch_ + ROUTES] dispatch --> auth[auth.gs withAuth_ token check] auth --> handlers[Route handlers: upsert_plan_item, list_plan_items, upload_photo, run_photo_ocr] handlers --> sheetsmod[sheets.gs upsertRow_ / getRows_ / deleteRow_] sheetsmod --> sheet[(Google Sheets: PlanItems / Library / Tags / Photos / Settings)] handlers --> calmod[calendar.gs writePlanItemToCalendar_] calmod --> gcal[(Shared Google Calendar)] handlers --> drivemod[drive.gs uploadPhotoToDrive_] drivemod --> drive[(Google Drive photo archive)] handlers --> visionmod[vision.gs runVisionOcrOnDriveFile_] visionmod --> vision[(Cloud Vision OCR)] trig[triggers.gs recurring photo-prompt event] --> gcal
Glossary of functions
- doGet / doPost / dispatch_ - The HTTP entry point: every request carries an
actionplus params (query string on GET, JSON body on POST), routed throughdispatch_to a handler in theROUTEStable and returned as JSON (gas/api.gs). - withAuth_ - Wraps every non-ping handler so it runs only when the request's shared token matches the
API_TOKENscript property; declared as a hoistingfunctionsoapi.gscan reference it at load time beforeauth.gsevaluates (gas/auth.gs). - upsert_plan_item - Creates or updates a plan item, mirrors it to the calendar and stores the event id back, validates backup pairing, and drags a paired backup's date/time along when its primary shifts (gas/api.gs).
- duplicate_plan_items_to_range - Copies a source date's primaries and their paired backups across an inclusive date range, minting new ids and rewriting
backup_for_idso the pairing carries over with fresh calendar events (gas/api.gs). - upsertRow_ - The lock-protected write chokepoint: finds a row by key and updates in place or appends, stamps
updated_at, and forces date/time columns to text format to avoid timezone-serial drift (gas/sheets.gs). - getRows_ / coerceCellForRead_ - Reads a tab in one pass into header-keyed objects, coercing Date cells back to timezone-stable strings and normalizing boolean columns (gas/sheets.gs).
- writePlanItemToCalendar_ - Idempotently creates or updates the GCal event for a plan item via its stored
gcal_event_id, builds the kid-lane title, defaults a 60-min end time, and tints backups a muted color (gas/calendar.gs). - list_calendar_events - Returns events in a date range across the family and read-only calendars, tagging each as
kidplan(matches a known plan-item event id) orexternalso the frontend can filter (gas/api.gs). - upload_photo / run_photo_ocr - Two-step paper-calendar capture: decode and store a base64 image to Drive plus a Photos row, then run Cloud Vision DOCUMENT_TEXT_DETECTION on that Drive file and persist the OCR text (gas/api.gs, gas/drive.gs, gas/vision.gs).
- apiCall / apiCallRaw - The frontend's single fetch wrapper: POSTs
{action, token, ...params}to the live GAS/execURL, throws onok:false, and unwraps thedataenvelope for callers (web/index.html). - setupRecurringPhotoPromptEvent - One-time idempotent setup that creates a recurring "Sync paper calendar" calendar event linking back to the app's photo view, guarded by a stored event id (gas/triggers.gs).
- setupSeedSheet - One-time idempotent provisioning that creates all five tabs with header rows and text-formatted date/time columns, then seeds presets only when empty (gas/sheets.gs).
- doGet / doPost / dispatch_ - The HTTP entry point: every request carries an
-
TrailKit

Visual inventory and gear organizer for outdoor activities. Build specific "loadouts" - combinations of gear and clothing - for different activities using a video-game style interface. Provides clean packing lists for these loadouts to help you prep for your day out.
HTML/JSFirst Claude project I built outside of work.

flowchart TD Build[build.mjs esbuild bundle] --> Dist[dist/TrailKit.html single file] Index[engine/index.js barrel] --> App[trailkit/app.js domain code] App --> Store[PlannerStore dispatch] App --> Drag[DragEngine init] App --> Rules[RulesEngine validate] App --> Persist[Persistence localStorage] Drag -->|onDragDrop| Rules Rules -->|valid| Place[placeTo and removeFrom] Place --> Store Store -->|subscriber Object.assign S| RenderAll[renderAll] RenderAll --> RenderStash[renderStash and renderMain and renderStats] RenderAll --> Persist Persist -->|restoreState| Store
Glossary of functions
- PlannerStore.dispatch - runs the middleware chain, applies the matching reducer (shallow-merging its partial state), then notifies subscribers; the single mutation path for all TrailKit actions. (src/engine/store.js)
- DragEngine.bindZone - wires
dragover/dragleave/dropon a DOM element and routes a completed drop to the app'sonDropcallback with the zone and index, centralizing drag plumbing. (src/engine/drag.js) - RulesEngine.validate - runs registered placement rules in order and returns the first failure, so a drop is checked before it commits. (src/engine/rules.js)
- onDragDrop - the app's drop handler passed to
DragEngine.init; validates viaRulesEngine, thenremoveFromthe source zone andplaceTothe target, restoring on failure and callingrenderAll. (src/trailkit/app.js) - ruleTypeMatch / ruleBackpackRequired / ruleCapacity - the three registered TrailKit rules (cheapest first) enforcing zone type maps, that a backpack exists before items go to main, and that capacity is not exceeded. (src/trailkit/app.js)
- renderAll - called after every dispatch; rebuilds all slot regions via
renderStash,renderBackpackSlot,renderWater,renderMain,renderWorn,renderStats, then persists. (src/trailkit/app.js) - setSampleGear - swaps the module-level
INVENTORYpointer betweenSAMPLE_INVENTORYandUSER_INVENTORYand flipsuseSampleGear, so all gear reads honor sample-vs-user mode. (src/trailkit/app.js) - persistState / restoreState - wrap
Persistence.save/loadwith a serializer that packsuseSampleGear, the user inventory, and all loadouts, re-pointingINVENTORYand re-syncing the liveSobject on restore. (src/trailkit/app.js) - exportXML / exportPackingLists / exportCSV - emit the
.trailkitXML file, a standalone print-ready packing-list HTML, and a CSV, all gated bymaybeExportwhich warns before exporting sample data. (src/trailkit/app.js) - StatsEngine.totalWeightKg - resolves an id list against an inventory array and sums item weights, the pure helper behind the loadout weight readout. (src/engine/stats.js)
-
PLDL

Download and research YouTube playlists. Recovers the titles of deleted or private videos by querying the Wayback Machine, so your archive isn't full of mystery entries.
HTML/JS Wayback Machine API
flowchart TD ui["index.html single-page UI"] -->|EventSource /api/events| sse["SSE broadcast hub"] ui -->|GET /api/playlist-info| info["getPlaylist (lib/playlist.js)"] ui -->|POST /api/recover| rec["recoverTitles + getTimelines"] ui -->|POST /api/download| dl["spawn yt-dlp"] ui -->|POST /api/download-recovered| arc["recoverMedia (lib/archive.js)"] server["server.js Express :3001"] --> info server --> rec server --> dl server --> arc server -->|startup| deps["ensureBinaries (lib/bindeps.js)"] info -->|optional, with API key| ytdata["getPlaylistItemDates (lib/ytdata.js)"] rec --> recover["recoverTitle: Wayback CDX + og:title"] rec --> timeline["getTimeline: bracket deletion window"] dl --> sse arc --> sse rec --> sse deps --> ytbin["yt-dlp / ffmpeg on PATH or ~/.pldl/bin"]
Glossary of functions
- getPlaylist - Enumerates every video in a playlist via
yt-dlp --flat-playlist -J, normalizing entries into a single array withavailableandavailabilityflags; the entry point for listing a playlist. (lib/playlist.js) - classify - Determines a video's state (public/private/deleted/unlisted/unavailable) by matching its placeholder title, since
--flat-playlistmode leaves yt-dlp'savailabilityfield null for every entry. (lib/playlist.js) - recoverTitle - Recovers a deleted video's title by finding its earliest HTTP-200 Wayback snapshot and scraping
og:title(falling back to<title>), the core deleted-title recovery primitive. (lib/recover.js) - recoverTitles - Runs
recoverTitleacross many video IDs with bounded concurrency (3) and a polite inter-request delay, emitting per-item progress callbacks. (lib/recover.js) - getTimeline - Brackets a video's deletion window by reading its full Wayback CDX snapshot history to find
lastSeenAlive(latest 200) andfirstSeenGone(first later 404/410). (lib/timeline.js) - deriveWindow - Pure helper that scans ordered CDX rows to compute the lastSeenAlive / firstSeenGone bracket and snapshot count, isolating the timeline logic from network fetching. (lib/timeline.js)
- recoverMedia - Best-effort recovery of an unavailable video from the Internet Archive: tries the
ytarchive:extractor for the stream, else saves thumbnail/info-json, always writing a title sidecar, then classifies the outcome. (lib/archive.js) - ensureBinaries - Resolves yt-dlp and ffmpeg at startup (PATH, then managed
~/.pldl/bin, then download), publishing their paths via env vars so the lib modules can shell out to them. (lib/bindeps.js) - getPlaylistItemDates - Optional YouTube Data API v3 enrichment that paginates
playlistItems.listinto aMap<videoId, {dateAdded, videoPublishedAt}>, giving an "alive on this date" signal where Wayback fails. (lib/ytdata.js) - broadcast - Fans a JSON event to every connected SSE client; all async download and recovery progress flows through this single hub rather than the originating HTTP response. (server.js)
- initDeps - Kicks off
ensureBinariesafter the server is listening and streams setup state over SSE, while a 503 guard holds yt-dlp-dependent endpoints until deps are ready. (server.js)
- getPlaylist - Enumerates every video in a playlist via
-
PlanFit

Workout planner that incorporates outdoor activities like mountain biking and hiking into your training schedule. Makes recurring schedules simple to plan out, auto-fills your calendar with a balanced workout plan, and lets you define workout categories (ie. cardio, leg day, upper body) and get a random workout of that type assigned.
HTML/JS
flowchart TD Load[loadState] --> Migrate[migrate to v1 shape] Migrate --> UserData[userData snapshot] UserData --> S[S active snapshot] Sample[freshSampleData] --> S S --> RenderCalendar[renderCalendar and renderLibrary and renderBot] Drop[desktop drop / mobile tap] --> PlaceOnDay[placeOnDay funnel] PlaceOnDay --> DropViolation[getDropViolation] DropViolation --> RestCheck[checkTypeRestViolation] PlaceOnDay -->|library| AddToDay[addToDay] PlaceOnDay -->|type pill| DayTags[S.dayTags] AddToDay --> Save[save to localStorage] AddToDay --> Schedule[getDaySchedule memo] Save --> RenderCalendar Auto[autoSchedule] --> DayTags Violations[getViolations] --> RenderCalendar
Glossary of functions
- loadState / migrate - read the latest (or legacy fallback)
localStoragepayload and lift it to the current v1 grouped shape, populatinguserData,uiPrefs, anduseSampleMode. (PlanFit-0.85.html) - save - serializes only
userData(neversampleData) into the grouped library/plan/schedule/log payload underplanfit_data_v1, after invalidating the schedule memo. (PlanFit-0.85.html) - placeOnDay - the single placement funnel for desktop drop and mobile tap; validates via
getDropViolation, then tags a day, adds a library workout, or moves a scheduled item. (PlanFit-0.85.html) - getDaySchedule - merges manually scheduled items with matching recurring items for a date, sorts activities before workouts, and memoizes the result. (PlanFit-0.85.html)
- getViolations - walks a +/-60 day window against
S.rulesto produce min-rest and max-gap violation badges shown on the calendar. (PlanFit-0.85.html) - checkTypeRestViolation - for a candidate date and type, finds the nearest prior/next matching days and rejects a placement that would break the type's min-rest rule; reused in both drop validation and auto-fill. (PlanFit-0.85.html)
- autoSchedule - greedily tags un-tagged future days in the current month by rule-urgency scoring, with a weekday
fallbackrotation when no rule is urgent. (PlanFit-0.85.html) - setSampleMode - swaps
Sbetween the in-memorysampleDataanduserDatasnapshots so sample-mode edits stay ephemeral. (PlanFit-0.85.html) - buildUserXML / loadUserXML - write user data as versioned
<planfit>XML with library/plan/schedule/log groupings, and import it using version-agnostic selectors that accept both the legacy 0.5 and current 1.0 layouts. (PlanFit-0.85.html) - escapeHTML / safeURL / newId / fmtDate - core safety and ID/date helpers used throughout: HTML-escape user content, sanitize URLs, mint collision-safe ids, and round-trip
Dateto"YYYY-MM-DD". (PlanFit-0.85.html) - MobileLayer.isMobile / hasTouch - split viewport-width (layout) from touch-capability (input) detection, so touch laptops get tap-to-place alongside drag-drop. (PlanFit-0.85.html)
- loadState / migrate - read the latest (or legacy fallback)
-
JobBuddy

Personalized job-application assistant. You send in a job (via a bookmarklet in your Bookmarks Bar, or drop a URL); it reads the JD, scores it against several resume/cover letter variations, manages application documents, helps answer application questions, watches Gmail for email updates on the application, and tracks the status in a Google Sheet.
Node + Express Google Apps Script Headless Claude CLI
flowchart TD subgraph capture [Capture - you curate the input] bm[bookmarklet] paste[paste box] disc[Discover tab] end bm --> cap[routes/pipeline.js POST /jd-capture] paste --> ing[routes/pipeline.js POST /api/ingest] disc --> ing ing --> manual[manual.js ingestUrl - jsdom ATS extract] manual --> cap cap --> score[subprocess.js scoreSnippet - both personas] score --> cli[claude CLI -p --dangerously-skip-permissions] cli --> score cap --> inbox[(Google Sheet: Inbox queue)] inbox --> prep[routes/pipeline.js POST /api/apply - Prep to apply] prep --> gen[subprocess.js rescoreFullJd + generateClIntro + generateMentionBullets] gen --> cli prep --> swap[routes/ats-swap.js - master-bank resume swaps] prep --> inbox inbox --> applied[routes/pipeline.js POST /api/applied - Mark applied] applied --> apps[(Google Sheet: Applications tracker)] scan[routes/status.js POST /api/scan-inbox - manual] --> gmail[gmail.js searchCompanyMail + classifyRejection] gmail --> cli scan --> appsGlossary of functions
- scoreSnippet - At capture time, spawns the
claudeCLI on the JD snippet to score it against both positioning personas, returning each variant's score plus the recommended persona (server/claude/subprocess.js). - rescoreFullJd - On "Prep to apply", re-scores the full JD body rather than just the snippet, since the recommended persona can change once the whole posting is read (server/claude/subprocess.js).
- generateClIntro / generateMentionBullets - Generate the tailored cover-letter intro paragraph and exactly three job-specific mention bullets for the chosen persona, fed the relevant master-bank bullets as context (server/claude/subprocess.js).
- runClaude / runClaudeJson - Spawn the
claudeCLI with--dangerously-skip-permissions, retry once on timeout, and regex-extract the fenced json block before parsing (server/claude/subprocess.js). - ingestUrl - Fetches a pasted or Discover-tab URL and extracts
{company, title, jd_body}via jsdom ATS parsers (Greenhouse/Lever/Ashby/JSON-LD), falling back to a needs-bookmarklet response when the page is auth-walled (server/manual.js). - fetchJdBody - On apply, fetches the full JD text for rows that came in metadata-only, unless the host is auth-walled (LinkedIn/Workday/iCIMS), which require the bookmarklet (server/jd-prefetch.js).
- canonicalIdentity - Normalizes a job URL to a stable identity (LinkedIn job id, or vendor plus path) so the same posting is not captured twice (server/identity.js).
- POST /api/apply/:job_id - The "Prep to apply" handler: ensures a full JD body exists, runs the full-JD rescore, then generates the cover-letter paragraph and three mention bullets and writes them back to the Inbox row (server/routes/pipeline.js).
- POST /api/applied/:job_id - Idempotent handler that promotes an Inbox row to a clean Applications-tab row (date, company, persona, score, link) and flips the Inbox status to
appliedwithout re-writing on a repeat click (server/routes/pipeline.js). - searchCompanyMail - On a manual "Scan inbox", searches Gmail for mail from or about each pending company since it was applied to, returning lightweight message objects (server/gmail.js).
- classifyRejection - Heuristically marks a rejection only when both a confirmation and a rejection phrase are present, escalating ambiguous cases to the CLI for a high-confidence call so statuses are never flipped on a guess (server/gmail.js).
- appendRows / updateRow - The Google Sheets client: positional appends and single-row updates against the two-tab
TAB_COLUMNSschema. The sheet is the only datastore (server/sheets.js).
- scoreSnippet - At capture time, spawns the
Earlier-stage projects
-
Salon

Create a visually appealing print arrangement for your art and photos - tell the app how many prints of various sizes you have, and it provides attractive patterns and a drag and drop interface to design your wall layout.
React Vite
flowchart TD main["src/main.jsx mounts App"] --> App["App component salon.jsx"] App --> Setup["Setup phase: prints inventory and wallDims"] App --> Layout["Layout phase: canvas and picker"] Setup --> expandPrints["expandPrints: rows x count to per-piece list"] expandPrints --> registry["LAYOUTS registry"] Layout --> registry registry --> gridLayout registry --> symmetricLayout registry --> galleryLayout registry --> arcLayout registry --> spiralLayout registry --> staircaseLayout gridLayout --> positions["positions in inches"] symmetricLayout --> positions galleryLayout --> positions arcLayout --> positions spiralLayout --> positions staircaseLayout --> positions positions --> computeStats["computeStats: coverage, balance, fits"] positions --> render["Render hung-prints; drag via applyDrag"] render --> paletteFor["paletteFor: deterministic palette per sourceId"]
Glossary of functions
- expandPrints - Flattens inventory rows (width x height x count) into one object per physical piece with a stable
idand asourceIdback-reference; this per-piece array is the common input every layout algorithm consumes. (salon.jsx) - gridLayout - Packs pieces into centered rows that wrap at the wall width (sorted tallest-first), then vertically centers the row stack; the tidy "conservatory" arrangement. (salon.jsx)
- symmetricLayout - Builds a mirrored composition by grouping identical sizes into pairs and solos, letting big solos absorb a smaller pair into a center+flanks "triple," then ordering rows middle-out so the largest sits on the axis. (salon.jsx)
- galleryLayout - Anchors the largest piece on a shared horizontal centerline and alternates remaining pieces outward left/right, the classic salon-hang "shared horizon." (salon.jsx)
- arcLayout - Places pieces along a circular arc of fixed angular span, computing radius from the wall-width chord and distributing pieces by parametric angle for a gently curved hang. (salon.jsx)
- spiralLayout - Seats the largest piece at center and walks the rest outward on an expanding spiral via a fixed angular step and growing radius, for a dynamic composition. (salon.jsx)
- staircaseLayout - Lays pieces small-to-large along an ascending diagonal with even horizontal gaps and a computed vertical step, suited to stairwells or vertical rooms. (salon.jsx)
- paletteFor - Hashes a
sourceIdinto a deterministic palette index so identical-size prints read as the same piece across every layout (hashing per-pieceidinstead would break that consistency). (salon.jsx) - computeStats - Scores a layout:
coverage(print area / wall area),balance(area-weighted horizontal moment about the wall center, normalized to ~[-1,1]), andfits(every piece inside the wall within a 0.5-inch tolerance). (salon.jsx) - applyDrag - Converts pixel mouse/touch deltas to inches using the live
scalefactor and clamps the dragged piece to the wall (with slight overflow slack), powering manual fine-tuning without recomputing the layout. (salon.jsx) - LAYOUTS - The registry object mapping each key (grid, symmetric, gallery, arc, spiral, staircase) to
{name, desc, fn}; the picker and the layout-recompute effect both read from it, so adding a layout is just a function plus one entry. (salon.jsx)
- expandPrints - Flattens inventory rows (width x height x count) into one object per physical piece with a stable
-
TellMeAStory

Single-file web app for video recut and remix. The app prompts you with a simple visual story idea - you get to use one video file to create it. Mark in/out points, arrange clips into a story, practice editing techniques.
HTML/JS FFMPEG
flowchart TD init["init: makeSessionId, renderSourceBar, renderClips, renderSequence"] state["state object: clips, sequence, inPoint, outPoint, playMode"] renderSourceBar["renderSourceBar — SAMPLES + customSources + upload/URL"] loadSource["loadSource — set video.src, wait loadedmetadata"] renderSourceBar --> loadSource loadSource --> state markIn["inBtn / outBtn set inPoint, outPoint"] markIn --> updateMarkers["updateMarkers draws scrubber range"] markIn --> saveClip["saveBtn builds clip {in,out,sourceId}"] saveClip --> state saveClip --> renderClips["renderClips — clip shelf cards"] renderClips --> addClipToSequence["addClipToSequence pushes {id,clipId,caption}"] addClipToSequence --> renderSequence["renderSequence — filmstrip + drag reorder"] renderSequence --> state playStory["playStoryBtn — playMode=story"] playStory --> playCurrent["playCurrentSequenceClip seeks clip.in"] timeupdate["video timeupdate watches currentTime vs clip.out"] timeupdate --> advanceStory["advanceStory increments currentSeqIdx"] advanceStory --> playCurrent playCurrent --> loadSourceGlossary of functions
- loadSource - Points the
<video>at a source URL, resets mark points, and onloadedmetadatarecordsduration; anerrorhandler surfaces the CORS failure case and suggests uploading the file instead (tell-me-a-story.html, line 823). - renderSourceBar - Rebuilds the source button row from
SAMPLESplusstate.customSources, and appends the upload-file and paste-URL utility buttons that create object-URL or pasted custom sources (tell-me-a-story.html, line 792). - inBtn.onclick / outBtn.onclick - Capture the current playhead as
inPoint/outPoint, enforcing that OUT comes after IN, then refresh the scrubber markers and Save button state (tell-me-a-story.html, line 931). - saveBtn.onclick - Builds a clip
{in, out, sourceId, sourceUrl, sourceName}from the marked range (rejecting sub-0.2s clips), pushes it tostate.clips, and re-renders the shelf (tell-me-a-story.html, line 951). - renderClips - Draws a card per saved clip showing its duration and source, wiring per-card preview, add-to-story, and delete actions (tell-me-a-story.html, line 975).
- previewClip - Plays a single clip from its
inpoint, re-loading its source first if the clip belongs to a source that is not currently loaded (tell-me-a-story.html, line 1001). - addClipToSequence / renderSequence - Append a sequence item
{id, clipId, caption}(multiple items may reference one clip), then render the draggable filmstrip with caption inputs, reorder via HTML5 drag-and-drop, and a running total runtime (tell-me-a-story.html, line 1019). - playCurrentSequenceClip - Plays the sequence item at
currentSeqIdx, showing its caption overlay and re-loading the source with a seek-to-incallback when the clip crosses a source boundary (tell-me-a-story.html, line 1111). - advanceStory - Increments
currentSeqIdxand plays the next clip, or ends the story ("— Fin —") when the sequence is exhausted; invoked by thetimeupdatehandler whencurrentTimereaches the clip'sout(tell-me-a-story.html, line 1137). - stopStory - Returns
playModeto'preview', pauses the video, clears the caption overlay and rec mark, and removes sequence highlighting (tell-me-a-story.html, line 1102). - tc - Formats seconds as
MM:SS.ttimecode, used across the transport, clip cards, and runtime totals (tell-me-a-story.html, line 769).
- loadSource - Points the
-
FlowState

Visual, drag-and-drop editor for Mermaid diagrams. Edit on a canvas; Mermaid code stays the source of truth.
Vite TypeScript Mermaid
flowchart TD main["src/main.ts — boot sequence"] tokens["tokens.ts — DARK_PALETTE (single source of truth)"] main --> applyPalette["applyPaletteToDocument sets CSS vars"] main --> initMermaid["initMermaid configures themeVariables"] main --> initUI["initUI wires data-action dispatch"] main --> initPanZoom["initPanZoom binds drag and wheel"] main --> initDragAndDrop["initDragAndDrop binds palette drops"] main --> initEditor["initEditor mounts Monaco + Mermaid language"] tokens --> applyPalette tokens --> initMermaid tokens --> initEditor initEditor --> editorState["state.editor + onDidChangeModelContent"] editorState --> scheduleRender["scheduleRender (400ms debounce)"] initDragAndDrop --> setEditorValue["setEditorValue (isEditorChange guard)"] initUI --> setEditorValue setEditorValue --> editorState scheduleRender --> renderDiagram["renderDiagram with renderSeq race guard"] renderDiagram --> renderArea["mermaid-render-area SVG + setStatus"] renderArea --> exportPNG["export.ts reads rendered SVG"]Glossary of functions
- applyPaletteToDocument - Writes the
DARK_PALETTEvalues onto:rootas CSS custom properties at runtime, makingtokens.tsthe single source the stylesheet, Mermaid theme, and Monaco theme all derive from (src/tokens.ts). - initMermaid - Initializes Mermaid with
startOnLoad: false, the palette-derivedthemeVariables, andsecurityLevel: 'loose', so renders are driven manually by the app rather than on page load (src/mermaid.ts). - scheduleRender - Sets status to SYNCING and debounces
renderDiagramby 400ms so rapid keystrokes collapse into a single render (src/mermaid.ts). - renderDiagram - Reads editor code, calls
mermaid.renderwith a monotonicrenderSeqid, and only writes the SVG if its seq is still the latest, preventing a slow stale render from overwriting a newer one (src/mermaid.ts). - initEditor - Creates the Monaco editor, registers the custom Mermaid Monarch language and FlowState theme, and on every content change (unless guarded) calls
scheduleRender(src/editor.ts). - setEditorValue - The only sanctioned way to write to the editor programmatically; it raises
state.isEditorChangearoundsetValueso the change handler ignores the synthetic edit and avoids a render loop (src/state.ts). - initDragAndDrop - Wires palette nodes as drag sources and the canvas as a drop target; on drop it generates a Mermaid line via
NODE_TEMPLATES, appends it (auto-adding aflowchart TDheader if missing), and re-renders (src/dnd.ts). - nextNodeId / findLastNodeId - Generate the next sequential node id (A, B, ... Z, A1, ...) and scan existing code for the last declared node, so arrow and dotted drops can connect to the previous node (src/dnd.ts).
- initUI - Binds every
[data-action]element to a handler in theACTIONSdispatch table and registers tab, template, modal, and keyboard-shortcut listeners (src/ui.ts). - initPanZoom - Binds mouse-drag panning, wheel zoom, and the fit/reset/zoom controls, applying a CSS transform to the stage element (src/panzoom.ts).
- exportMMD / exportPNG - Download the raw Mermaid source as a
.mmdblob, or rasterize the rendered SVG onto a 2x canvas over the surface background and download it as PNG (src/export.ts).
- applyPaletteToDocument - Writes the
-
FamilyStories

Captures family members' lives as an interactive, time-based weave - each person a thread, shared moments the braid.
HTML/JS SVG
flowchart TD subgraph v1["v1.html — timeline prototype"] v1state["app.state — array in localStorage key heritage_data"] v1save["app.saveStory reads form, pushes story"] v1render["app.render groups by decade into card grid"] v1save --> v1persist["app.persistAndRender sorts by year, saves"] v1persist --> v1render v1state --> v1render end subgraph v2["v2.html — data-model sandbox (retired visually)"] v2load["loadState migrates legacy key to familystories_v2"] v2state["app.state — people, stories, currentView"] v2add["app.addPerson / app.addStory"] v2save["app.save writes localStorage, re-renders"] v2render["app.render → renderTree or renderTimeline"] v2tree["buildTreeHTML recursive nested ul/li walk"] v2load --> v2state v2add --> v2save v2save --> v2render v2render --> v2tree v2state --> v2render end mockup["mockups/home.html — static SVG weave (agreed direction)"]Glossary of functions
- app.saveStory - Reads the title/year/text/photo form, builds a story object with an object-URL photo, and pushes it into state before persisting (v1.html).
- app.persistAndRender - Sorts stories by year, writes the array to the
heritage_datalocalStorage key, and triggers a re-render (v1.html). - app.render (v1) - Groups all stories into decade buckets and rebuilds the timeline as a card grid via direct innerHTML interpolation (v1.html).
- loadState - Reads the
familystories_v2key, and on first run performs a one-time migration that copies and deletes the legacyheritage_v2key (v2.html). - escapeHtml - Escapes user-provided strings before they are interpolated into innerHTML, used on every v2 render path (v2.html).
- app.addPerson - Adds a person to the tree with an id, name, and optional
parentId, then saves and refreshes the parent dropdown (v2.html). - app.addStory - Captures a story and, if a photo is attached, reads it via
FileReader.readAsDataURLso the image survives reloads inside localStorage (v2.html). - app.render (v2) - Syncs the view switcher and dispatches to
renderTreeorrenderTimelinebased onstate.currentView(v2.html). - buildTreeHTML - Recursively walks people by
parentIdto emit the nested<ul>/<li>structure that the pure-CSS connector lines style into a family tree (v2.html). - renderTimeline - Sorts stories by year and rebuilds the timeline as a single escaped innerHTML string (v2.html).
-
puffy-draw

Turn a 2d sketch into a 3d model. Inspired by 3d graphics project called "Teddy" I encountered many years ago (created using a technical document presented at a conference).
React three.js
flowchart TD pointer["Pointer handlers — handlePointerDown / Move / Up"] presets["PRESETS map — loadPreset"] simplify["simplifyStroke drops near-duplicate points"] stroke["stroke state (useState)"] redraw["redraw — 2D canvas ink + fill"] rebuild["mesh-rebuild effect — px to math coords"] build["buildGeometry — pipeline entry"] smooth["smoothPolygon then resamplePolygon"] ear["earClip triangulation (signedArea winding)"] subdiv["subdivideMesh — 4-way midpoint"] dist["distToBoundary — sqrt height field"] geom["THREE.BufferGeometry — front and back mirrored"] scene["Three.js scene — animate loop + hand-rolled orbit"] pointer --> simplify simplify --> stroke presets --> stroke pointer --> redraw stroke --> rebuild rebuild --> build build --> smooth smooth --> ear ear --> subdiv subdiv --> dist dist --> geom geom --> sceneGlossary of functions
- buildGeometry - Top-level pipeline entry: smooths, resamples, normalizes, triangulates, subdivides, and inflates a polygon into a
THREE.BufferGeometrywith mirrored front/back faces and computed normals (PuffyDraw.jsx). - earClip - Ear-clipping triangulation of the boundary polygon, auto-reversing winding via
signedAreaso stroke direction does not matter (PuffyDraw.jsx). - subdivideMesh - Applies N levels of 4-way midpoint subdivision (capped at 5) with a midpoint cache, producing a denser mesh for a smoother inflated surface (PuffyDraw.jsx).
- distToBoundary - Returns the minimum distance from a vertex to any boundary edge; this drives the inflation height field via
z = sqrt(dist) * puffiness(PuffyDraw.jsx). - smoothPolygon / resamplePolygon - Smooth the raw stroke and resample it to an even ring of ~28–96 boundary points before triangulation (PuffyDraw.jsx).
- simplifyStroke - Drops points closer than a minimum distance so the captured stroke is not over-dense before meshing (PuffyDraw.jsx).
- PuffyDraw - The default-exported React component; manages canvas, Three.js refs, and all UI state, splitting imperative data into
useRefand UI-visible data intouseState(PuffyDraw.jsx). - redraw - Imperatively repaints the 2D drawing canvas (paper texture, grid, ink stroke, and closed fill) during and after drawing (PuffyDraw.jsx).
- handlePointerDown / Move / Up - Capture the freehand stroke; on release
handlePointerUpsimplifies it and commits tostrokestate, which triggers the mesh rebuild (PuffyDraw.jsx). - loadPreset - Loads one of the built-in
PRESETS(blob, star, fish, leaf, cloud) as the current stroke, bypassing freehand drawing (PuffyDraw.jsx). - PRESETS - A map of name to
(w, h) => points[]generators that produce parametric closed shapes in canvas pixel coordinates (PuffyDraw.jsx).
- buildGeometry - Top-level pipeline entry: smooths, resamples, normalizes, triangulates, subdivides, and inflates a polygon into a
ABOUT
Who's behind this
I'm Tyler. I've spent eight years in B2B SaaS building GTM Enablement and Customer Education programs at companies like Simpro, KarmaCheck, Buildout, Apto, and RE/MAX. My day job has always been about closing the gap between what a product can do and what the people selling, supporting, and using it actually understand.
Since picking up Claude Code, I've started closing the gap from the other side too - building the tools myself. This site is where those builds live.
To see my portfolio for GTM Enablement and Customer Education, visit tgeddes.com.