From 25dfd4ed1e9749855fbdf9007dd899803e1a8f57 Mon Sep 17 00:00:00 2001 From: Ben Zwick Date: Tue, 26 May 2026 14:51:54 +0800 Subject: [PATCH] Add a documentation page with auto-generated screenshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a dedicated documentation site at /docs/, built as a second Vite entry point alongside the app and deployed to the same GitHub Pages site. It documents every feature — the matrix, team, tasks, schedule, and insights tabs, the goal bar and global controls, theming, CSV/JSON import, and the Talk2View assistant — plus a from-the-code walkthrough of the two algorithms (quadrant weights, capacity budget, and burnout thresholds for assignment; slot scoring and energy/concentration matching for scheduling). A philosophy section records where the method came from: the Eisenhower matrix the team kept drawing on paper and whiteboards while running internal projects at Talk2View Pty Ltd, and the annotations about who would enjoy, be good at, and have room for each task that grew into the pleasure/talent/capacity model. Every screenshot on the page is generated by Playwright rather than captured by hand. A spec under tests/e2e/screenshots reuses the existing e2e fixtures and seeds to drive the live app — seeding the demo project, freezing the clock so the schedule sub-views fill with real blocks, and loading the real fonts — then writes thirteen PNGs into public/screenshots/. The deploy workflow regenerates them before `vite build` (which copies them into dist/screenshots/), so the documentation never drifts out of date with the deployed UI. The capture step is best-effort: a transient failure degrades the docs to graceful placeholders rather than blocking the site deploy. The screenshots are gitignored and can be regenerated locally with `npm run screenshots`; the spec lives outside the e2e testDir and uses its own config, so `npm run test:e2e` never runs it. To let the docs reuse the app's exact look without bundling the whole application, the theme machinery, colour and style objects, and the small Pill/Slider/SectionHead primitives move out of App.jsx into a shared src/ui/theme.jsx that both entries import. The documentation page wraps that module in the real ThemeProvider, so theme presets, dark mode, and font choices carry across and persist via the same storage key. Finally, every primary control in the app — the five tabs, the header buttons, the add-member/task/category/stakeholder actions, and the schedule view switcher — gains a tooltip (src/ui/Tooltip.jsx) that pairs a one-line description with a "Learn more" link deep into the matching /docs/ section, and the header and footer gain a direct link to the documentation. --- .github/workflows/deploy.yml | 11 + .gitignore | 3 + docs/index.html | 30 + package.json | 3 +- playwright.screenshots.config.ts | 43 ++ src/App.jsx | 489 +++----------- src/docs/DocsApp.jsx | 761 ++++++++++++++++++++++ src/docs/main.jsx | 13 + src/talk2view/tools.js | 3 +- src/ui/Tooltip.jsx | 112 ++++ src/ui/theme.jsx | 378 +++++++++++ tests/e2e/screenshots/docs.screenshots.ts | 107 +++ vite.config.js | 10 + 13 files changed, 1554 insertions(+), 409 deletions(-) create mode 100644 docs/index.html create mode 100644 playwright.screenshots.config.ts create mode 100644 src/docs/DocsApp.jsx create mode 100644 src/docs/main.jsx create mode 100644 src/ui/Tooltip.jsx create mode 100644 src/ui/theme.jsx create mode 100644 tests/e2e/screenshots/docs.screenshots.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 39d10d1..838ec48 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -30,6 +30,17 @@ jobs: - name: Install dependencies run: npm ci + # Regenerate the documentation screenshots from the live app so they're + # always current, then build — `vite build` copies public/screenshots/ + # into dist/screenshots/. Non-fatal: a transient capture failure should + # never block deploying the app; the docs page degrades to placeholders. + - name: Install Playwright (chromium) + run: npx playwright install --with-deps chromium + + - name: Generate documentation screenshots + run: npm run screenshots + continue-on-error: true + - name: Build run: npm run build diff --git a/.gitignore b/.gitignore index 5600995..6a2b03b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ dist-ssr /playwright-report/ /blob-report/ /playwright/.cache/ + +# Auto-generated documentation screenshots (regenerated on deploy) +/public/screenshots/ diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..ed4005f --- /dev/null +++ b/docs/index.html @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + The Joy Matrix — Documentation + + +
+ + + diff --git a/package.json b/package.json index 495a21e..b3d7475 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "test:e2e:chromium": "playwright test --project=chromium-laptop", "test:e2e:ui": "playwright test --ui", "test:e2e:report": "playwright show-report", - "test:e2e:install": "playwright install --with-deps" + "test:e2e:install": "playwright install --with-deps", + "screenshots": "playwright test --config playwright.screenshots.config.ts" }, "dependencies": { "@talk2view/sdk": "^0.5.0", diff --git a/playwright.screenshots.config.ts b/playwright.screenshots.config.ts new file mode 100644 index 0000000..7544d6c --- /dev/null +++ b/playwright.screenshots.config.ts @@ -0,0 +1,43 @@ +import { defineConfig, devices } from "@playwright/test"; + +// Dedicated config for generating documentation screenshots. Separate from +// playwright.config.ts (the e2e suite) so it runs a single browser/viewport, +// loads real fonts, and writes into public/screenshots/. Invoke with +// `npm run screenshots`. + +const PORT = 5173; + +export default defineConfig({ + testDir: "./tests/e2e/screenshots", + testMatch: "**/*.screenshots.ts", + fullyParallel: false, + workers: 1, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + timeout: 60_000, + expect: { timeout: 10_000 }, + outputDir: "test-results/screenshots-artifacts", + reporter: [["list"]], + + use: { + baseURL: `http://localhost:${PORT}`, + viewport: { width: 1280, height: 900 }, + deviceScaleFactor: 2, + actionTimeout: 15_000, + navigationTimeout: 20_000, + }, + + projects: [ + { + name: "docs", + use: { ...devices["Desktop Chrome"], viewport: { width: 1280, height: 900 }, deviceScaleFactor: 2 }, + }, + ], + + webServer: { + command: "npm run dev -- --port 5173 --strictPort", + url: `http://localhost:${PORT}`, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/src/App.jsx b/src/App.jsx index 87b75b4..6043dfa 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,11 +1,19 @@ -import React, { useState, useEffect, useMemo, useContext, createContext } from "react"; +import React, { useState, useEffect, useMemo } from "react"; import { Plus, X, Sparkles, AlertTriangle, Trash2, RefreshCw, Zap, Heart, Brain, Battery, ArrowRight, Target, Users, ListTodo, Grid3x3, Activity, Sun, Moon, Palette, RotateCcw, Download, Upload, Github, Linkedin, Instagram, - Calendar + Calendar, BookOpen } from "lucide-react"; import JoyMatrixChat from "./talk2view/JoyMatrixChat"; +import { + colors, PRESETS, DEFAULT_FONTS, DEFAULT_THEME, FONT_OPTIONS, CUSTOMIZE_SLOTS, + loadTheme, saveTheme, effectiveFonts, fontStack, buildGoogleFontsUrl, + detectInitialMode, migrateTheme, useViewportWidth, useTheme, ThemeProvider, + Pill, Slider, SectionHead, + card, mutedLabel, inputBare, tabBtn, tabBtnActive, btnGhost, btnPrimary, btnIcon, warnBox, +} from "./ui/theme.jsx"; +import Tooltip, { docsLink } from "./ui/Tooltip.jsx"; import { FUZZY_VALUES, FUZZY_LABELS, formatDueDate, isOverdue } from "./scheduling/dueDate"; import { DAYS, DAY_LABELS, WEEKDAYS, weekdayNineToFive, normaliseRanges, weeklyAvailableHours } from "./scheduling/availability"; import { DAYTIMES, DAYTIME_LABELS, SCORE_LABELS, defaultWindows } from "./scheduling/windows"; @@ -23,7 +31,6 @@ import CsvImportModal from "./import/CsvImportModal"; // ───────────────────────────────────────────────────────────────────────────── const STORAGE_KEY = "joy-matrix-state-v1"; -const THEME_STORAGE_KEY = "joy-matrix-theme-v1"; const SCHEMA_VERSION = 5; const DEMO_STATE = { @@ -370,219 +377,6 @@ function triggerJsonDownload(filename, payload) { setTimeout(() => URL.revokeObjectURL(url), 0); } -function loadTheme() { - try { - const v = localStorage.getItem(THEME_STORAGE_KEY); - if (v) return JSON.parse(v); - } catch (e) {} - return null; -} -function saveTheme(theme) { - try { localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(theme)); } catch (e) {} -} - -// ───────────────────────────────────────────────────────────────────────────── -// UI primitives -// ───────────────────────────────────────────────────────────────────────────── - -// Logical color names map to CSS variables set by ThemeProvider. Component -// code keeps using `colors.ink` etc.; the actual hex resolves at paint time -// from whatever theme is active, so a swap is instant and re-render-free. -const colors = { - paper: "var(--joy-paper)", - paperDeep: "var(--joy-paper-deep)", - ink: "var(--joy-ink)", - inkSoft: "var(--joy-ink-soft)", - rule: "var(--joy-rule)", - rust: "var(--joy-rust)", - rustDeep: "var(--joy-rust-deep)", - teal: "var(--joy-teal)", - ochre: "var(--joy-ochre)", - bone: "var(--joy-bone)", -}; - -// Each theme carries its own light + dark palette. The header sun/moon -// toggle flips `mode` within the active themeId. Themes can be added by -// dropping another entry into this map with both variants defined. -export const PRESETS = { - talk2view: { - label: "Talk2View", - defaultFonts: { head: "Geist", body: "Geist", mono: "Geist Mono" }, - light: { - "--joy-paper": "#f5f4f0", - "--joy-paper-deep": "#eceae3", - "--joy-ink": "#1a1a18", - "--joy-ink-soft": "#5a5854", - "--joy-rule": "rgba(26,26,24,0.12)", - "--joy-rust": "#c25543", - "--joy-rust-deep": "#9b3f30", - "--joy-teal": "#3aa89a", - "--joy-ochre": "#c98a2c", - "--joy-bone": "#fbfaf6", - }, - dark: { - "--joy-paper": "#0f1413", - "--joy-paper-deep": "#171d1c", - "--joy-ink": "#e8ebe9", - "--joy-ink-soft": "#9aa6a3", - "--joy-rule": "rgba(232,235,233,0.14)", - "--joy-rust": "#e07050", - "--joy-rust-deep": "#f08870", - "--joy-teal": "#5ccab8", - "--joy-ochre": "#e8a647", - "--joy-bone": "#1a2120", - }, - }, - workbook: { - label: "Workbook", - defaultFonts: { head: "Fraunces", body: "Geist", mono: "Geist Mono" }, - light: { - "--joy-paper": "#f4ebdb", - "--joy-paper-deep": "#ece1cb", - "--joy-ink": "#1c1916", - "--joy-ink-soft": "#3a342c", - "--joy-rule": "rgba(28,25,22,0.14)", - "--joy-rust": "#b8492a", - "--joy-rust-deep": "#8e2f17", - "--joy-teal": "#2a5d5d", - "--joy-ochre": "#c98a2c", - "--joy-bone": "#fbf6ec", - }, - dark: { - "--joy-paper": "#16110d", - "--joy-paper-deep": "#1f1812", - "--joy-ink": "#ebe2d0", - "--joy-ink-soft": "#a8997f", - "--joy-rule": "rgba(235,226,208,0.14)", - "--joy-rust": "#e07050", - "--joy-rust-deep": "#f59072", - "--joy-teal": "#6fb3b3", - "--joy-ochre": "#e8a647", - "--joy-bone": "#221b15", - }, - }, -}; - -// Map from the previous flat-preset names to the new {themeId, mode} shape. -// Used to migrate localStorage entries written before this refactor. -const LEGACY_PRESET_MAP = { - workbook: { themeId: "workbook", mode: "light" }, - midnight: { themeId: "workbook", mode: "dark" }, -}; - -const DEFAULT_FONTS = { head: "Fraunces", body: "Geist", mono: "Geist Mono" }; -const DEFAULT_THEME = { themeId: "talk2view", mode: "light", overrides: {}, fonts: {} }; - -// Resolve the active font for each slot: explicit user pick wins, otherwise -// the active theme's defaultFonts, otherwise the global DEFAULT_FONTS. -function effectiveFonts(theme) { - const preset = PRESETS[theme.themeId] ?? PRESETS[DEFAULT_THEME.themeId]; - const themeDefaults = preset?.defaultFonts || DEFAULT_FONTS; - const picks = theme.fonts || {}; - return { - head: picks.head || themeDefaults.head || DEFAULT_FONTS.head, - body: picks.body || themeDefaults.body || DEFAULT_FONTS.body, - mono: picks.mono || themeDefaults.mono || DEFAULT_FONTS.mono, - }; -} - -// Curated set per slot. Heading + body share one list so the user can pair -// any serif or sans with any role (Geist heading + Lora body, or vice -// versa). Mono stays separate since it must be monospaced. -const PROSE_FONTS = [ - // Sans-serif - "Geist", "Inter", "DM Sans", "Manrope", "Nunito Sans", "Outfit", - // Serif - "Fraunces", "Playfair Display", "Lora", "DM Serif Display", "Cormorant Garamond", "Roboto Slab", -]; -const MONO_FONTS = ["Geist Mono", "JetBrains Mono", "IBM Plex Mono", "Space Mono", "Roboto Mono"]; - -const FONT_OPTIONS = { - head: PROSE_FONTS, - body: PROSE_FONTS, - mono: MONO_FONTS, -}; - -const FONT_FALLBACK = { - head: 'serif', - body: 'ui-sans-serif, system-ui, sans-serif', - mono: 'ui-monospace, monospace', -}; - -function fontStack(family, slot) { - return `"${family}", ${FONT_FALLBACK[slot]}`; -} - -function buildGoogleFontsUrl({ head, body, mono }) { - const enc = (f) => f.replace(/ /g, "+"); - // Per-slot weight + italic axes. Head needs the widest range - // (italic + heavy display weights); body needs regular + medium-bold; - // mono needs only regular + medium. When the same family is picked for - // multiple slots, take the most expensive spec so all uses get satisfied. - const HEAD_SPEC = "ital,wght@0,400;0,500;0,600;0,700;0,900;1,400;1,700;1,900"; - const BODY_SPEC = "wght@400;500;600;700"; - const MONO_SPEC = "wght@400;500"; - const SPEC_RANK = { [HEAD_SPEC]: 3, [BODY_SPEC]: 2, [MONO_SPEC]: 1 }; - const specs = new Map(); - const add = (family, spec) => { - const prev = specs.get(family); - if (!prev || SPEC_RANK[spec] > SPEC_RANK[prev]) specs.set(family, spec); - }; - add(head, HEAD_SPEC); - add(body, BODY_SPEC); - add(mono, MONO_SPEC); - const params = [...specs.entries()].map(([f, s]) => `family=${enc(f)}:${s}`).join("&"); - return `https://fonts.googleapis.com/css2?${params}&display=swap`; -} - -// Live viewport width for responsive inline styles. Returns 1024 on the -// server and during the first paint so layouts default to the desktop -// branch — mobile-specific tweaks kick in once mounted. -function useViewportWidth() { - const [w, setW] = useState(() => (typeof window === "undefined" ? 1024 : window.innerWidth)); - useEffect(() => { - const onResize = () => setW(window.innerWidth); - window.addEventListener("resize", onResize); - return () => window.removeEventListener("resize", onResize); - }, []); - return w; -} - -// Pick an initial mode that respects the user's OS preference on first visit. -function detectInitialMode() { - if (typeof window === "undefined" || !window.matchMedia) return "light"; - return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; -} - -// Forward-only migration: pull a saved theme into the current shape. -function migrateTheme(saved) { - if (!saved || typeof saved !== "object") return null; - // Old shape used a flat `preset` name. Map it onto {themeId, mode}. - if (saved.preset && !saved.themeId) { - const mapped = LEGACY_PRESET_MAP[saved.preset] || { themeId: "talk2view", mode: "light" }; - return { ...DEFAULT_THEME, ...saved, ...mapped, preset: undefined }; - } - // Defensive: unknown themeId falls back to the default. - if (saved.themeId && !PRESETS[saved.themeId]) { - return { ...saved, themeId: DEFAULT_THEME.themeId }; - } - return saved; -} - -// The main slots exposed in the color picker. Other CSS variables (rule, -// paper-deep, rust-deep) intentionally stay tied to the preset so users -// don't have to balance ten swatches to get a coherent look. -const CUSTOMIZE_SLOTS = [ - { key: "--joy-paper", label: "Paper", sub: "background" }, - { key: "--joy-ink", label: "Ink", sub: "text" }, - { key: "--joy-rust", label: "Rust", sub: "DO accent" }, - { key: "--joy-teal", label: "Teal", sub: "SCHEDULE accent" }, - { key: "--joy-ochre", label: "Ochre", sub: "DELEGATE accent" }, -]; - -const ThemeContext = createContext({ theme: DEFAULT_THEME, setTheme: () => {} }); -function useTheme() { return useContext(ThemeContext); } - function CustomizePanel({ onClose }) { const { theme, setTheme } = useTheme(); const isPhone = useViewportWidth() < 480; @@ -730,84 +524,6 @@ function CustomizePanel({ onClose }) { ); } -function ThemeProvider({ children }) { - const [theme, setThemeState] = useState(() => { - const saved = migrateTheme(loadTheme()); - if (saved) return { ...DEFAULT_THEME, ...saved }; - return { ...DEFAULT_THEME, mode: detectInitialMode() }; - }); - useEffect(() => { saveTheme(theme); }, [theme]); - const setTheme = (next) => setThemeState((t) => (typeof next === "function" ? next(t) : next)); - const preset = PRESETS[theme.themeId] ?? PRESETS[DEFAULT_THEME.themeId]; - const presetVars = preset[theme.mode] ?? preset.light; - const fonts = effectiveFonts(theme); - const fontVars = { - "--joy-font-head": fontStack(fonts.head, "head"), - "--joy-font-body": fontStack(fonts.body, "body"), - "--joy-font-mono": fontStack(fonts.mono, "mono"), - }; - const vars = { ...presetVars, ...fontVars, ...(theme.overrides || {}) }; - - // (Re)inject the Google Fonts link whenever the font selection changes. - useEffect(() => { - const id = "joy-matrix-fonts"; - const url = buildGoogleFontsUrl(fonts); - let link = document.getElementById(id); - if (link && link.href === url) return; - if (link) link.remove(); - link = document.createElement("link"); - link.id = id; link.rel = "stylesheet"; link.href = url; - document.head.appendChild(link); - }, [fonts.head, fonts.body, fonts.mono]); - - return ( - -
{children}
-
- ); -} - -function Pill({ children, tone = "ink" }) { - const tones = { - ink: { bg: "rgba(28,25,22,0.06)", color: colors.ink, bd: "rgba(28,25,22,0.14)" }, - rust: { bg: "rgba(184,73,42,0.10)", color: colors.rustDeep, bd: "rgba(184,73,42,0.30)" }, - teal: { bg: "rgba(42,93,93,0.10)", color: colors.teal, bd: "rgba(42,93,93,0.30)" }, - warn: { bg: "rgba(184,73,42,0.18)", color: colors.rustDeep, bd: "rgba(184,73,42,0.50)" }, - }; - const t = tones[tone]; - return ( - {children} - ); -} - -function Slider({ value, onChange, min, max, step = 1, color = colors.ink, label, testId }) { - const pct = ((value - min) / (max - min)) * 100; - return ( -
-
- {label} - {value > 0 ? `+${value}` : value} -
- onChange(Number(e.target.value))} - style={{ - width: "100%", height: 4, borderRadius: 999, appearance: "none", - background: `linear-gradient(to right, ${color} 0%, ${color} ${pct}%, rgba(28,25,22,0.12) ${pct}%, rgba(28,25,22,0.12) 100%)`, - cursor: "pointer", - }} - /> -
- ); -} - // ───────────────────────────────────────────────────────────────────────────── // Main // ───────────────────────────────────────────────────────────────────────────── @@ -1042,25 +758,40 @@ function AppInner() { THE JOY MATRIX · v1
- - - - + + + + + + + + + + + + - - + + + + + + + + docs +
@@ -1134,21 +865,22 @@ function AppInner() { ["schedule","Schedule",Calendar], ["insights","Insights",Activity], ].map(([key, label, Icon]) => ( - + + + ))} @@ -1179,6 +911,18 @@ function AppInner() { open source · benzwick/joy-matrix + + + read the documentation +
add member} + action={} /> {state.members.length === 0 && ( @@ -1798,7 +1542,7 @@ function TasksView({ state, update, addTask, removeTask, editing, setEditing, as eyebrow="03" title="The Tasks" sub="Score each person's pleasure & talent for each task. Be honest." - action={} + action={} /> @@ -1987,7 +1731,7 @@ function ScheduleView({ state, assignments }) { eyebrow="04" title="The Schedule" sub="Tasks placed into real time, matched to each person's availability and energy curve." - action={} + action={} /> {schedule.conflicts.length > 0 && ( @@ -2653,19 +2397,6 @@ function BigStat({ icon: Icon, label, value, tone = "ink" }) { // Shared bits // ───────────────────────────────────────────────────────────────────────────── -function SectionHead({ eyebrow, title, sub, action }) { - return ( -
-
-
{eyebrow} ──
-

{title}

-
{sub}
-
- {action} -
- ); -} - function CategoriesBar({ state, update }) { const categories = state.categories || []; const addCategory = () => { @@ -2724,9 +2455,11 @@ function CategoriesBar({ state, update }) { }}> ))} - + + +
); } @@ -2789,7 +2522,9 @@ function StakeholdersBar({ state, update }) { }}> ))} - + + + ); } @@ -2934,63 +2669,3 @@ function AutoGrowTextarea({ value, onChange, placeholder, style }) { ); } -// ───────────────────────────────────────────────────────────────────────────── -// Styles -// ───────────────────────────────────────────────────────────────────────────── - -const card = { - padding: 16, borderRadius: 12, - background: colors.bone, border: `1px solid ${colors.rule}`, -}; - -const mutedLabel = { - fontFamily: "var(--joy-font-mono)", fontSize: 10, - letterSpacing: "0.12em", textTransform: "uppercase", color: colors.inkSoft, -}; - -const inputBare = { - width: "100%", border: "none", background: "transparent", outline: "none", - color: colors.ink, fontFamily: "var(--joy-font-body)", fontSize: 15, padding: "4px 0", -}; - -const tabBtn = { - display: "inline-flex", alignItems: "center", gap: 5, - padding: "7px 12px", borderRadius: 999, - background: "transparent", border: `1px solid transparent`, - fontFamily: "var(--joy-font-mono)", fontSize: 11, letterSpacing: "0.06em", - color: colors.inkSoft, cursor: "pointer", textTransform: "uppercase", - whiteSpace: "nowrap", -}; -const tabBtnActive = { - background: colors.teal, color: "#ffffff", border: `1px solid ${colors.teal}`, -}; - -const btnGhost = { - display: "inline-flex", alignItems: "center", gap: 4, - padding: "5px 10px", borderRadius: 999, - background: "transparent", border: `1px solid ${colors.rule}`, - fontFamily: "var(--joy-font-mono)", fontSize: 10, letterSpacing: "0.06em", - color: colors.inkSoft, cursor: "pointer", textTransform: "uppercase", -}; - -const btnPrimary = { - display: "inline-flex", alignItems: "center", gap: 5, - padding: "8px 14px", borderRadius: 999, - background: colors.teal, color: "#ffffff", border: "none", - fontFamily: "var(--joy-font-mono)", fontSize: 11, letterSpacing: "0.06em", - cursor: "pointer", textTransform: "uppercase", -}; - -const btnIcon = { - display: "inline-flex", alignItems: "center", justifyContent: "center", - width: 26, height: 26, borderRadius: 999, - background: "transparent", border: `1px solid ${colors.rule}`, - color: colors.inkSoft, cursor: "pointer", -}; - -const warnBox = { - marginTop: 10, padding: "8px 10px", borderRadius: 8, - background: "rgba(184,73,42,0.10)", border: `1px solid rgba(184,73,42,0.3)`, - color: colors.rustDeep, fontSize: 12, fontFamily: "var(--joy-font-head)", - display: "flex", alignItems: "center", gap: 6, -}; diff --git a/src/docs/DocsApp.jsx b/src/docs/DocsApp.jsx new file mode 100644 index 0000000..2f242c0 --- /dev/null +++ b/src/docs/DocsApp.jsx @@ -0,0 +1,761 @@ +import React, { useState, useEffect } from "react"; +import { + Sun, Moon, ArrowLeft, Heart, Brain, Battery, Zap, AlertTriangle, + Grid3x3, Users, ListTodo, Calendar, Activity, Sparkles, Target, +} from "lucide-react"; +import { + colors, card, mutedLabel, btnGhost, SectionHead, Pill, useViewportWidth, useTheme, +} from "../ui/theme.jsx"; + +const BASE = import.meta.env.BASE_URL || "/"; +const shot = (name) => `${BASE}screenshots/${name}.png`; + +// Order matches the on-page sections; ids double as scroll anchors and as +// the link targets used by the in-app tooltips (see src/ui/Tooltip.jsx). +const TOC = [ + ["overview", "Overview"], + ["philosophy", "Philosophy & origin"], + ["concepts", "Core concepts"], + ["goal", "Goal bar & controls"], + ["matrix", "The Matrix tab"], + ["team", "The Team tab"], + ["tasks", "The Tasks tab"], + ["schedule", "The Schedule tab"], + ["insights", "The Insights tab"], + ["assignment-algorithm", "Assignment algorithm"], + ["scheduling-algorithm", "Scheduling algorithm"], + ["themes", "Themes & fonts"], + ["import-export", "Data & portability"], + ["assistant", "Talk2View assistant"], +]; + +export default function DocsApp() { + const isPhone = useViewportWidth() < 760; + + // The page renders client-side, so the browser's native hash scroll on load + // finds no target yet. Re-run it once React has mounted the sections, and on + // any later hashchange (e.g. the in-app tooltip "Learn more →" deep links). + useEffect(() => { + const scrollToHash = () => { + const id = decodeURIComponent((window.location.hash || "").slice(1)); + if (!id) return; + document.getElementById(id)?.scrollIntoView({ block: "start" }); + }; + const raf = requestAnimationFrame(scrollToHash); + window.addEventListener("hashchange", scrollToHash); + return () => { + cancelAnimationFrame(raf); + window.removeEventListener("hashchange", scrollToHash); + }; + }, []); + + return ( +
+ + +
+ +
+ + + + + + + + + + + + + + + +
+
+
+
+ ); +} + +// ───────────────────────────── chrome ───────────────────────────── + +function TopBar() { + const { theme, setTheme } = useTheme(); + const isDark = theme.mode === "dark"; + const toggle = () => setTheme((t) => ({ ...t, mode: t.mode === "dark" ? "light" : "dark" })); + return ( +
+
+ + back to the app + +
+ THE JOY MATRIX · DOCS +
+ +
+
+ ); +} + +function Toc({ isPhone }) { + return ( + + ); +} + +function Hero() { + return ( +
+
+ THE FIELD GUIDE +
+

+ Everything the Joy Matrix does +

+

+ A complete tour of the app — the matrix, the team model, task scoring, + the auto-scheduler, the insights, and the two algorithms underneath it + all. Every screenshot on this page is regenerated automatically from the + live app on each deploy, so it never drifts out of date. +

+
+ ); +} + +// ───────────────────────────── building blocks ───────────────────────────── + +function Section({ id, eyebrow, title, sub, children }) { + return ( +
+ +
+ {children} +
+
+ ); +} + +function P({ children }) { + return

{children}

; +} + +function Lead({ children }) { + return

{children}

; +} + +function H3({ children }) { + return ( +

{children}

+ ); +} + +function Bullets({ items }) { + return ( + + ); +} + +function Note({ children, tone = "teal" }) { + const accent = tone === "rust" ? colors.rust : tone === "ochre" ? colors.ochre : colors.teal; + return ( +
+ +
{children}
+
+ ); +} + +function Term({ name, children, icon: Icon, color = colors.teal }) { + return ( +
+
+ {Icon && } + {name} +
+
{children}
+
+ ); +} + +function Code({ children }) { + return ( + {children} + ); +} + +function Figure({ name, caption, alt }) { + const [failed, setFailed] = useState(false); + return ( +
+ {failed ? ( +
+ screenshot generated on deploy +
+ ) : ( + {alt setFailed(true)} + style={{ + width: "100%", display: "block", borderRadius: 12, + border: `1px solid ${colors.rule}`, boxShadow: "0 8px 30px rgba(0,0,0,0.10)", + }} + /> + )} + {caption && ( +
+ {caption} +
+ )} +
+ ); +} + +// ───────────────────────────── sections ───────────────────────────── + +function Overview() { + return ( +
+ + The Joy Matrix is a multidimensional extension of the classic + Eisenhower Matrix, built for small teams. It sorts tasks by{" "} + urgency × importance, then goes a step further: it + scores how each task lands on each person across three human + dimensions — pleasure, talent, and{" "} + capacity — and auto-assigns the work to move from where + you are (A) to where you want to be (B) as fast as possible{" "} + without burning anyone out. + +
+

+ It runs entirely in your browser. There is no account, no server, and no + tracking — your project lives in localStorage and can be + exported to a single JSON file at any time. The whole app is five tabs: +

+ Matrix — the urgency/importance grid with auto-assignments., + <>Team — your people, their capacity, and their strengths., + <>Tasks — scoring each task and each person's fit for it., + <>Schedule — tasks placed into real time on a calendar., + <>Insights — joy, load, burnout risk, and a plain-language read., + ]} /> +
+ ); +} + +function Philosophy() { + return ( +
+ + The method started on a whiteboard. While running internal projects at{" "} + Talk2View Pty Ltd, the team kept reaching for the + Eisenhower Matrix to triage work — drawing the four quadrants on paper or + a whiteboard and dropping tasks into them. + +

+ It worked for deciding what mattered, but it kept missing{" "} + who should do it. Two tasks could sit in the same quadrant and + still be a great fit for one person and a slow, joyless grind for + another. The matrix had no opinion about that — so the whiteboard + version kept growing little annotations next to each task: who'd enjoy + it, who was actually good at it, and who had any room left that week. +

+

+ The Joy Matrix is that whiteboard, formalised. Urgency and importance + decide what to do and in what order; pleasure, talent, and capacity + decide who it should land on. The guiding principle is{" "} + sustainable speed: the fastest route from A to B is the + one that doesn't quietly destroy the people walking it. +

+ + Capacity is treated as a ceiling, not a target. The goal + is never to fill everyone to the brim — it's to get from A to B while + keeping the team intact for whatever comes after B. + +
+ ); +} + +function Concepts() { + return ( +
+

The four quadrants

+

+ Every task is placed by two 1–5 sliders. A task is{" "} + urgent when urgency ≥ 3 and important{" "} + when importance ≥ 3. That yields the familiar grid: +

+ +

+ The small numbers (1–4) shown in the app are a workflow + order, not grid positions: delegate first so others can start in + parallel, then do your own urgent work, then invest in the + important-but-not-urgent quadrant, and finally drop the residue. +

+ +

The five dimensions

+ + How soon this needs to happen. Drives the horizontal split of the matrix. + + + How much it matters to the goal. Drives the vertical split of the matrix. + + + Relative load units the task consumes from a person's weekly budget. Also + sets how much calendar time the scheduler reserves for it. + + + Per person, per task: how much they enjoy this kind of work. Negative + means it drains them. + + + Per person, per task: how good they are at it. Negative means it's a + stretch. + + + Per person: their current bandwidth this week — not a fixed trait. It + changes week to week and becomes their effort budget. + + + Pleasure and talent are scored per (person, task), so the + same task can be a joy for one teammate and a slog for another. Tasks can + also carry a difficulty (1–5) used by the scheduler to + match hard work to high-concentration hours. + +
+ ); +} + +function QuadrantGrid() { + const cells = [ + { label: "DO", sub: "urgent · important", color: colors.rustDeep, n: 2 }, + { label: "SCHEDULE", sub: "important · not urgent", color: colors.teal, n: 3 }, + { label: "DELEGATE", sub: "urgent · not important", color: colors.ochre, n: 1 }, + { label: "ELIMINATE", sub: "neither — drop", color: colors.inkSoft, n: 4 }, + ]; + return ( +
+ {cells.map((c) => ( +
+
+ {c.label} + {String(c.n).padStart(2, "0")} +
+
{c.sub}
+
+ ))} +
+ ); +} + +function GoalAndControls() { + return ( +
+

+ At the top sits your goal, framed as a journey from A{" "} + (where you are now) to B (where you want to be). It's + free text and it feeds the closing narrative on the Insights tab, so it's + worth writing honestly. +

+

Header controls

+ light / dark — flip the colour mode; your choice is remembered., + <>customize — open the panel to change theme preset, accent colours, and fonts., + <>import — load a project from a Joy-Matrix JSON export or a CSV of tasks., + <>export — download the whole project as a versioned JSON file., + <>demo — replace everything with the built-in demo project., + <>clear — wipe back to an empty slate., + ]} /> + + Hover any button in the app to see a short tooltip with a{" "} + Learn more → link that jumps straight to the matching + section of this guide. + +
+ ); +} + +function MatrixTab() { + return ( +
+
+

+ Each quadrant lists the tasks that fall into it. Every task shows its + auto-assigned owner (→ name), a FOR tag if + it's tied to a stakeholder, and its U/I/E{" "} + mini-stats. If the assigned person is over capacity, a{" "} + ⚠ risk badge appears. +

+

+ Tap any task to jump to its editor on the Tasks tab. The{" "} + more toggle opens a legend that explains the workflow + order and the reasoning behind each quadrant. ELIMINATE tasks are listed + but never assigned — they're candidates to drop. +

+
+ ); +} + +function TeamTab() { + return ( +
+
+

+ Add a teammate with + add member. Each card has: +

+ Capacity slider (−3…+3) — sets the weekly effort budget. The label translates it into plain words, from "overloaded" to "lots of bandwidth"., + <>Category baselines — for each category, a default pleasure and talent score that auto-fills new tasks in that category (still overridable per task)., + <>Live stats — once they have work assigned: task count, load vs budget, a joy index, and a talent-fit index., + <>Burnout / strain warnings — flagged when assigned effort pushes them past their budget., + ]} /> +
+

+ Budget is derived from capacity: budget = max(0.5, 4 + capacity){" "} + effort units — so −3 ≈ 1 unit, 0 ≈ 4 units, +3 ≈ 7 units. +

+
+ ); +} + +function TasksTab() { + return ( +
+
+

+ + add task creates a task; click it to open the editor. + Above the list you can manage categories (groupings that + drive baseline auto-fill) and stakeholders (who a task is + for). +

+
+

The editor has three parts:

+ Category & stakeholder — choosing a category re-seeds any per-member scores you haven't manually touched., + <>Urgency / Importance / Effort — three 1–5 sliders that place the task in the matrix and size its schedule footprint., + <>Per-member fit — a pleasure and talent slider for each teammate. Scores auto-filled from a category baseline are replaced the moment you drag them., + ]} /> +
+ ); +} + +function ScheduleTab() { + return ( +
+

+ The scheduler takes the assignments and lays them onto a rolling 7-day + calendar in 30-minute blocks, respecting each person's availability + windows and their per-time-of-day energy and concentration. Switch + between four views with the day / week / month / year buttons. +

+
+ Week — one swimlane per person across the seven days, with total hours., + <>Day — an hour-by-hour grid; step through days with prev/next., + <>Month — a calendar with scheduled hours and block counts per day., + <>Year — a heatmap of scheduled load across the whole year., + ]} /> +
+

+ Inputs the scheduler reads, all set elsewhere: a member's{" "} + availability (per-day time windows), their{" "} + energy & concentration windows (morning / midday / + afternoon / evening), a task's due date (an exact date or + a fuzzy label like this-week), and its{" "} + difficulty. If a task can't fit before its deadline it's + surfaced in a Conflicts list with the reason. +

+
+
+
+
+
+ ); +} + +function InsightsTab() { + return ( +
+
+ Headline stats — total joy index, total talent fit, and counts of people who are burnt out or stretched., + <>Joy × load per person — a bar per teammate showing utilisation against budget; overflow past 100% is striped., + <>Pain points — assignments where someone neither enjoys nor is good at the work, with a suggestion (swap, train, or drop)., + <>This week's schedule — scheduled vs idle hours per person, flagged when over budget., + <>The read — a plain-language summary of project health, ending with your A → B goal., + ]} /> + + The joy index is Σ pleasure × effort and the talent index is{" "} + Σ talent × effort across each person's assigned tasks — so a + big draining task hurts the score more than a small one. + +
+ ); +} + +function AssignmentAlgorithm() { + return ( +
+

+ Assignment is greedy. Tasks are processed in priority order (by quadrant, + then by urgency × importance), and each is given to the best-fit person + available, depleting that person's budget as it goes. ELIMINATE tasks are + never assigned. +

+

The fit score

+

For each candidate the app computes:

+
+{`score = w.talent × talent + + w.pleasure × pleasure + + w.capacity × (remaining ÷ budget × 3) + − overshoot × 1.2`} +
+

+ The quadrant decides the weights, so the same person can be the right + pick for one quadrant and the wrong pick for another: +

+ + DO leans on talent — get urgent, important work to whoever's best at it., + <>SCHEDULE leans on pleasure — strategic work goes to whoever it energises., + <>DELEGATE leans on capacity — hand off to whoever has room., + ]} /> +

+ The overshoot × 1.2 term penalises pushing someone past + their budget. A person is flagged strained once their + remaining budget goes negative, and at burnout risk once + it drops below −1.5. +

+
+ ); +} + +function WeightsTable() { + const rows = [ + ["DO", "0.50", "0.20", "0.30", colors.rustDeep], + ["SCHEDULE", "0.30", "0.50", "0.20", colors.teal], + ["DELEGATE", "0.20", "0.30", "0.50", colors.ochre], + ]; + const th = { ...mutedLabel, padding: "6px 10px", textAlign: "left" }; + const td = { padding: "8px 10px", fontFamily: "var(--joy-font-mono)", fontSize: 13, borderTop: `1px solid ${colors.rule}` }; + return ( +
+ + + + + + + + + + + {rows.map(([q, t, p, c, col]) => ( + + + + + + + ))} + +
QuadrantTalentPleasureCapacity
{q}{t}{p}{c}
+
+ ); +} + +function SchedulingAlgorithm() { + return ( +
+

+ Tasks with a hard deadline are placed first (earliest deadline wins), + then everything else by the same quadrant priority. For each task the + scheduler reserves ceil(effort × 1.5 × 2) half-hour slots — + roughly 1.5 hours of calendar time per effort unit. +

+

How a slot is scored

+

+ It collects every free 30-minute slot inside the owner's availability + before the deadline, scores each one, and takes the best, then orders + them earliest-first: +

+
+{`slot = −|effort − energy| × 2 (match heavy work to high-energy hours) + − |difficulty − focus| × 2 (match hard work to high-focus hours) + + pleasure × 0.5 + + talent × 0.3`} +
+

+ Energy and concentration are set on a 1–3 scale per time-of-day and + mapped onto the 1–5 effort/difficulty scale (1→1, 2→3, 3→5) so they + compare cleanly. Availability windows, the deadline, and one-use-per-slot + capacity are hard constraints; the four terms above are{" "} + soft objectives. When the free slots run out before the + required amount is placed, the task lands in Conflicts. +

+
+ ); +} + +function Themes() { + return ( +
+
+

+ The app ships with two presets — Talk2View and{" "} + Workbook — each with a light and dark palette. The{" "} + customize panel lets you switch preset and mode, recolour + five accent slots (paper, ink, rust, teal, ochre), and choose heading, + body, and monospace fonts from a curated list (loaded on demand from + Google Fonts). Reset buttons restore the preset's colours or fonts. This + very documentation page uses the same theme system, so whatever you pick + carries across. +

+
+ ); +} + +function DataPortability() { + return ( +
+

+ Everything is stored locally under the key{" "} + joy-matrix-state-v1. Nothing is sent anywhere. Two ways in + and one way out: +

+ Export — downloads a versioned JSON envelope (joy-matrix-YYYY-MM-DD.json) containing the whole project. Round-trippable., + <>Import JSON — load a previous export back in (it migrates older schema versions forward automatically)., + <>Import CSV — drop in a task export from another tool; a mapping dialog opens., + ]} /> +
+

+ The CSV importer auto-detects columns, lets you remap them, optionally + creates any referenced members / categories / stakeholders, previews the + first few drafted tasks, and can either append to or replace your current + tasks. Most project tools (Linear, Jira, Asana, Trello, Notion, …) export + a compatible CSV directly. +

+ + The Joy Matrix is released to the public domain under{" "} + Creative Commons Zero (CC0 1.0). Use it, fork it, + ship it — no attribution required. + +
+ ); +} + +function Assistant() { + return ( +
+

+ A conversational assistant (powered by the Talk2View SDK) sits in the + bottom-right corner. It can answer questions about your project — + "who's at burnout risk?", "show me everyone's load", "what do the four + quadrants mean?" — and it can act: add members and tasks, set capacity + and scores, switch tabs, change the theme, and export the project. Edits + that change your data ask for confirmation first. +

+
+ ); +} + +function Footer() { + return ( +
+
─── designed for sustainable speed ───
+ + back to the app + +
© 2026 Benny Zwick. Copyright waived under the Creative Commons Zero (CC0) License.
+
+ ); +} diff --git a/src/docs/main.jsx b/src/docs/main.jsx new file mode 100644 index 0000000..21990be --- /dev/null +++ b/src/docs/main.jsx @@ -0,0 +1,13 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { ThemeProvider } from '../ui/theme.jsx' +import DocsApp from './DocsApp.jsx' +import '../index.css' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + + + , +) diff --git a/src/talk2view/tools.js b/src/talk2view/tools.js index 5dd31e4..fee7760 100644 --- a/src/talk2view/tools.js +++ b/src/talk2view/tools.js @@ -12,7 +12,8 @@ // { ok: false, error, candidates? } failure (candidates listed when a // name lookup was ambiguous) -import { PRESETS, buildExportEnvelope, quadrantOf } from "../App"; +import { buildExportEnvelope, quadrantOf } from "../App"; +import { PRESETS } from "../ui/theme.jsx"; import { clamp, findCategory, findMember, findStakeholder, findTask } from "./lookup"; import { parseDueDateInput, formatDueDate, FUZZY_VALUES } from "../scheduling/dueDate"; import { parseRangesInput, DAYS, normaliseRanges, weekdayNineToFive } from "../scheduling/availability"; diff --git a/src/ui/Tooltip.jsx b/src/ui/Tooltip.jsx new file mode 100644 index 0000000..6b38993 --- /dev/null +++ b/src/ui/Tooltip.jsx @@ -0,0 +1,112 @@ +// Hover/focus tooltip that pairs a short description with a deep link into +// the documentation page (/docs/#anchor). Wrap any control: +// +//