diff --git a/CLAUDE.md b/CLAUDE.md index e1567af..f2974c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,9 +67,9 @@ Netlify reads the `netlify.toml` **inside that base dir**, so the sites stay iso sprint/PI filters; timezone-aware times) and **Sprints & PIs → PI Roadmap**. **USWDS palette, light-only** (no dark theme). **No generator/Action** — data is hand-entered JSON bundled at BUILD via `import.meta.glob` (no runtime fetch). Source of truth = compact **`data/meetings.json` + `data/pis.json`** - arrays; adds/edits (meetings **and** PIs) stage in an in-app **Changes cart** → **one prefilled new-file - PR** creating `dse-hub/data/changes/.json` (array of `ChangeDoc`), which the loaders **merge** over the - canonical arrays at load (upsert by id, newest `ts` wins) — token-free, conflict-free (unique filenames), + arrays; adds/edits/**deletes** (meetings **and** PIs) stage in an in-app **Changes cart** → **one prefilled + new-file PR** creating `dse-hub/data/changes/.json` (array of `ChangeDoc`, `op: upsert|delete`), which the + loaders **merge** over the canonical arrays at load (upsert/delete by id, newest `ts` wins) — token-free, conflict-free (unique filenames), same idea as leave-dashboard overrides. `dse-hub/scripts/compact.mjs` + `.github/workflows/dse-hub-compact.yml` fold change files back into the canonical arrays on merge. **Recurrence** (`src/meetings/recurrence.ts`): weekly/monthly/sprint/sprint-week/tbd → real dates against diff --git a/docs/DSE_HUB.md b/docs/DSE_HUB.md index d90d56f..b881634 100644 --- a/docs/DSE_HUB.md +++ b/docs/DSE_HUB.md @@ -15,14 +15,16 @@ in-app **prefilled PRs** (no backend, no token, no runtime fetch). ## Data model (GitHub-only, merged at BUILD) - Source of truth = compact **`data/meetings.json`** and **`data/pis.json`** (single JSON arrays, empty optional fields omitted). Bundled at build via `import.meta.glob` — no runtime fetch, no CDN staleness. -- Every add/edit (meeting or PI) is a **`ChangeDoc`** `{ kind:"meeting"|"pi", op:"upsert", ts, data, label }` - staged in the in-app **Changes cart** (`src/ChangesContext.tsx`, `localStorage dse-hub:changes`). +- Every add/edit/**delete** (meeting or PI) is a **`ChangeDoc`** `{ kind:"meeting"|"pi", op:"upsert"|"delete", ts, data, label }` + staged in the in-app **Changes cart** (`src/ChangesContext.tsx`, `localStorage dse-hub:changes`). A + **delete** keeps the full record in `data` so loaders/compact can read its `id`; the "🗑 Delete meeting" + button (shared `MeetingDetail`) stages one after a confirm. - **Submit PR** builds ONE file `data/changes/-.json` (array of ChangeDocs) and opens the prefilled `github.com/NASA-IMPACT/veda-github-actions/new/main?filename=&value=` page — the same token-free flow as leave-dashboard's overrides. The user just clicks *commit* on GitHub. - Loaders (`src/meetings/data.ts`, `src/pi/data.ts`) import the canonical arrays + glob `data/changes/*.json` - (`src/changesData.ts`) and **merge**: upsert by `data.id`, newest `ts` wins. So a merged PR shows up on the - next Netlify build. + (`src/changesData.ts`) and **merge** by `data.id`, newest `ts` wins: `upsert` sets the record, `delete` + removes it. So a merged PR shows up on the next Netlify build. - `scripts/compact.mjs` + `.github/workflows/dse-hub-compact.yml` **fold** change files back into the canonical arrays on merge to `main` (and delete the folded files) — keeps `data/changes/` small at scale. - **Why single canonical files, not one-per-record:** scales to hundreds without hundreds of files, and gzip @@ -59,6 +61,14 @@ schedule (via the app: **Sprints & PIs → Edit → Add to changes → Submit PR border or the columns go ragged. - Netlify: set **base directory = `dse-hub`**; the first deploy fails if run before `dse-hub/` exists on the built branch ("base directory not found") — deploy after the merge lands on `main`. +- **Light-only ⇒ pin native controls.** `` (index.html) + + `:root { color-scheme: light }` (styles.css) stop a dark-mode OS from rendering ` patch({ team: e.target.value })} placeholder="Disasters — or a new team" /> - {teams.map((t) => + patch({ team: v })} options={teams} placeholder="Disasters — or a new team" />
@@ -72,17 +77,12 @@ export default function AddMeetingForm({ mode, editId, draft, setDraft, teams, o
- patch({ category: e.target.value })} + onChange={(v) => patch({ category: v })} + options={CATEGORY_OPTIONS} placeholder="Sprint Planning, Check In, …" /> - - {["Sprint Planning","Sprint Review","Check In","Tag Up","Standup","Sync","Team Lead Sync","Portal Design","Backlog Grooming","Retro","Project Office"].map((c) => ( -
diff --git a/dse-hub/src/meetings/Combobox.tsx b/dse-hub/src/meetings/Combobox.tsx new file mode 100644 index 0000000..742d1ef --- /dev/null +++ b/dse-hub/src/meetings/Combobox.tsx @@ -0,0 +1,89 @@ +import { useRef, useState, type KeyboardEvent } from "react"; +import { useClickAway } from "../useClickAway"; + +interface Props { + value: string; + onChange: (v: string) => void; + options: string[]; + placeholder?: string; + id?: string; +} + +// A light-themed autocomplete that replaces the native : on a dark-mode OS Chrome renders +// the datalist popup as an unstyled dark, misaligned dropdown. Free text is allowed — pick a +// suggestion or type a brand-new value (e.g. a new team). Modeled on TimePicker's dropdown pattern. +export default function Combobox({ value, onChange, options, placeholder, id }: Props) { + const [open, setOpen] = useState(false); + const [active, setActive] = useState(-1); + const ref = useRef(null); + useClickAway(ref, () => setOpen(false), open); + + const q = value.trim().toLowerCase(); + const matches = q ? options.filter((o) => o.toLowerCase().includes(q)) : options; + + function choose(o: string) { + onChange(o); + setOpen(false); + setActive(-1); + } + + function onKeyDown(e: KeyboardEvent) { + if (e.key === "ArrowDown") { + e.preventDefault(); + setOpen(true); + setActive((a) => Math.min(a + 1, matches.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setActive((a) => Math.max(a - 1, 0)); + } else if (e.key === "Enter" && open && active >= 0 && matches[active]) { + e.preventDefault(); + choose(matches[active]); + } else if (e.key === "Escape") { + setOpen(false); + } + } + + return ( +
+ { onChange(e.target.value); setOpen(true); setActive(-1); }} + onFocus={() => setOpen(true)} + onKeyDown={onKeyDown} + /> + + {open && matches.length > 0 && ( +
+ {matches.map((o, i) => ( + + ))} +
+ )} +
+ ); +} diff --git a/dse-hub/src/meetings/HoldToDelete.tsx b/dse-hub/src/meetings/HoldToDelete.tsx new file mode 100644 index 0000000..f348a5c --- /dev/null +++ b/dse-hub/src/meetings/HoldToDelete.tsx @@ -0,0 +1,60 @@ +import { useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent } from "react"; + +interface Props { + onComplete: () => void; + ms?: number; // hold duration; default 2000 + label?: string; + holdingLabel?: string; +} + +// Press-and-HOLD to delete: the action only fires after a continuous `ms` hold, and a fill bar +// animates across the button in lockstep. Releasing (pointer up / leave / cancel) before the end +// aborts. Replaces a confirm() dialog with a deliberate, hard-to-trigger-by-accident gesture. +export default function HoldToDelete({ + onComplete, + ms = 2000, + label = "🗑 Delete meeting", + holdingLabel = "Hold to delete…", +}: Props) { + const [holding, setHolding] = useState(false); + const timer = useRef(null); + + function start(primary: boolean) { + if (!primary || holding) return; + setHolding(true); + timer.current = window.setTimeout(() => { + timer.current = null; + setHolding(false); + onComplete(); + }, ms); + } + function cancel() { + if (timer.current != null) { + clearTimeout(timer.current); + timer.current = null; + } + setHolding(false); + } + + return ( + + ); +} diff --git a/dse-hub/src/meetings/MeetingCalendar.tsx b/dse-hub/src/meetings/MeetingCalendar.tsx index dc5e4b0..8fdc4f3 100644 --- a/dse-hub/src/meetings/MeetingCalendar.tsx +++ b/dse-hub/src/meetings/MeetingCalendar.tsx @@ -18,6 +18,7 @@ interface Props { today: string; draftIds: Set; onEdit?: (m: Meeting) => void; + onDelete?: (m: Meeting) => void; } // What's shown when a user clicks — either a single meeting or a whole day's list. @@ -33,6 +34,7 @@ function MeetingPopoverContent({ isDraft, onClose, onEdit, + onDelete, viewTz, }: { meeting: Meeting; @@ -40,6 +42,7 @@ function MeetingPopoverContent({ isDraft: boolean; onClose: () => void; onEdit?: () => void; + onDelete?: () => void; viewTz: string; }) { const sourceTz = meeting.schedule.tz ?? "America/Chicago"; @@ -61,7 +64,7 @@ function MeetingPopoverContent({
- +
); @@ -73,6 +76,7 @@ function DayPopoverContent({ draftIds, onClose, onEdit, + onDelete, viewTz, }: { iso: string; @@ -80,6 +84,7 @@ function DayPopoverContent({ draftIds: Set; onClose: () => void; onEdit?: (m: Meeting) => void; + onDelete?: (m: Meeting) => void; viewTz: string; }) { const [expanded, setExpanded] = useState(null); @@ -119,7 +124,11 @@ function DayPopoverContent({ {isOpen && (
- onEdit(m) : undefined} /> + onEdit(m) : undefined} + onDelete={onDelete ? () => onDelete(m) : undefined} + />
)}
@@ -130,7 +139,7 @@ function DayPopoverContent({ ); } -export default function MeetingCalendar({ meetings, pi, today, draftIds, onEdit }: Props) { +export default function MeetingCalendar({ meetings, pi, today, draftIds, onEdit, onDelete }: Props) { const { tz: viewTz } = useViewTz(); const [month, setMonth] = useState(() => monthKey(parseISO(today))); const [selection, setSelection] = useState(null); @@ -195,6 +204,11 @@ export default function MeetingCalendar({ meetings, pi, today, draftIds, onEdit onEdit?.(m); } + function handleDelete(m: Meeting) { + setSelection(null); + onDelete?.(m); + } + return (
@@ -341,6 +355,7 @@ export default function MeetingCalendar({ meetings, pi, today, draftIds, onEdit isDraft={draftIds.has(selection.meeting.id)} onClose={closeSelection} onEdit={onEdit ? () => handleEdit(selection.meeting) : undefined} + onDelete={onDelete ? () => handleDelete(selection.meeting) : undefined} viewTz={viewTz} /> ) : ( @@ -350,6 +365,7 @@ export default function MeetingCalendar({ meetings, pi, today, draftIds, onEdit draftIds={draftIds} onClose={closeSelection} onEdit={onEdit ? handleEdit : undefined} + onDelete={onDelete ? handleDelete : undefined} viewTz={viewTz} /> )} diff --git a/dse-hub/src/meetings/MeetingCategories.tsx b/dse-hub/src/meetings/MeetingCategories.tsx index 4531498..2069efa 100644 --- a/dse-hub/src/meetings/MeetingCategories.tsx +++ b/dse-hub/src/meetings/MeetingCategories.tsx @@ -13,6 +13,7 @@ import { piCalendar } from "./data"; interface Props { meetings: Meeting[]; onEdit?: (m: Meeting) => void; + onDelete?: (m: Meeting) => void; } function matchWithin(m: Meeting, q: string): boolean { @@ -22,7 +23,7 @@ function matchWithin(m: Meeting, q: string): boolean { return hay.includes(q.toLowerCase()); } -export default function MeetingCategories({ meetings, onEdit }: Props) { +export default function MeetingCategories({ meetings, onEdit, onDelete }: Props) { const { tz: viewTz } = useViewTz(); const today = toISO(new Date()); const [selected, setSelected] = useState(null); @@ -120,7 +121,11 @@ export default function MeetingCategories({ meetings, onEdit }: Props) { {isOpen && (
- onEdit(m) : undefined} /> + onEdit(m) : undefined} + onDelete={onDelete ? () => onDelete(m) : undefined} + />
)}
diff --git a/dse-hub/src/meetings/MeetingDetail.tsx b/dse-hub/src/meetings/MeetingDetail.tsx index 2303938..5ac2597 100644 --- a/dse-hub/src/meetings/MeetingDetail.tsx +++ b/dse-hub/src/meetings/MeetingDetail.tsx @@ -5,10 +5,19 @@ import { formatRangeInTz } from "../tz"; import { toISO } from "../calendar"; import { nextOccurrence } from "./recurrence"; import { piCalendar } from "./data"; +import HoldToDelete from "./HoldToDelete"; // The full, expanded content for a meeting — reused as the hover body of a list card and as the // body of the calendar popover, so both show the same complete information. -export default function MeetingDetail({ meeting, onEdit }: { meeting: Meeting; onEdit?: () => void }) { +export default function MeetingDetail({ + meeting, + onEdit, + onDelete, +}: { + meeting: Meeting; + onEdit?: () => void; + onDelete?: () => void; +}) { const { schedule, join } = meeting; const { tz: viewTz } = useViewTz(); const today = toISO(new Date()); @@ -77,9 +86,10 @@ export default function MeetingDetail({ meeting, onEdit }: { meeting: Meeting; o
)} - {onEdit && ( + {(onEdit || onDelete) && (
- + {onEdit && } + {onDelete && }
)} diff --git a/dse-hub/src/meetings/MeetingList.tsx b/dse-hub/src/meetings/MeetingList.tsx index 78755c4..8cae2e5 100644 --- a/dse-hub/src/meetings/MeetingList.tsx +++ b/dse-hub/src/meetings/MeetingList.tsx @@ -12,6 +12,7 @@ interface Props { today: string; draftIds: Set; onEdit?: (m: Meeting) => void; + onDelete?: (m: Meeting) => void; } // Compact "when" line for the collapsed card: the human schedule text, plus the next real date. @@ -25,7 +26,7 @@ function whenSummary(m: Meeting, today: string, pi: PiCalendar | null, viewTz: s return { text: m.schedule.text, next }; } -export default function MeetingList({ meetings, pi, today, draftIds, onEdit }: Props) { +export default function MeetingList({ meetings, pi, today, draftIds, onEdit, onDelete }: Props) { const { tz: viewTz } = useViewTz(); if (!meetings.length) { @@ -72,7 +73,11 @@ export default function MeetingList({ meetings, pi, today, draftIds, onEdit }: P )}
- onEdit(m) : undefined} /> + onEdit(m) : undefined} + onDelete={onDelete ? () => onDelete(m) : undefined} + />
Hover / focus for details diff --git a/dse-hub/src/meetings/MeetingTracker.tsx b/dse-hub/src/meetings/MeetingTracker.tsx index ca4c334..986cea3 100644 --- a/dse-hub/src/meetings/MeetingTracker.tsx +++ b/dse-hub/src/meetings/MeetingTracker.tsx @@ -80,6 +80,17 @@ export default function MeetingTracker() { setDraft(meetingToDraft(m)); setMtgForm({ mode: "edit", editId: m.id }); } + // Staged (not applied) — the "hold 2s to delete" gesture in HoldToDelete is the confirmation, and + // it still only lands as a reviewable entry in the Changes cart / PR, never an instant removal. + function deleteMeeting(m: Meeting) { + stage({ + kind: "meeting", + op: "delete", + ts: new Date().toISOString(), + data: m, + label: `Delete meeting: ${m.name}`, + }); + } return (
@@ -120,13 +131,13 @@ export default function MeetingTracker() {
{view === "list" && ( - + )} {view === "calendar" && ( - + )} {view === "categories" && ( - + )} {mtgForm && ( diff --git a/dse-hub/src/meetings/data.ts b/dse-hub/src/meetings/data.ts index cac3dd3..88e8485 100644 --- a/dse-hub/src/meetings/data.ts +++ b/dse-hub/src/meetings/data.ts @@ -11,10 +11,11 @@ export function loadMeetings(): Meeting[] { if (m && m.id && m.name && m.team) map.set(m.id, m); } for (const d of allChangeDocs()) { - if (d.kind === "meeting" && d.op === "upsert") { - const m = d.data as Meeting; - if (m && m.id) map.set(m.id, m); - } + if (d.kind !== "meeting") continue; + const m = d.data as Meeting; + if (!m || !m.id) continue; + if (d.op === "delete") map.delete(m.id); + else map.set(m.id, m); } return [...map.values()].sort((a, b) => a.team.localeCompare(b.team) || a.name.localeCompare(b.name)); } diff --git a/dse-hub/src/styles.css b/dse-hub/src/styles.css index 34d098f..a305840 100644 --- a/dse-hub/src/styles.css +++ b/dse-hub/src/styles.css @@ -1,6 +1,9 @@ /* DSE Hub — USWDS-flavored palette as CSS variables (light theme; structured so a dark theme can be layered later). No component library — hand-rolled, matching the leave-dashboard approach. */ :root { + /* Light-only app: keep native controls (datalist/select popups, date picker, scrollbars) light + so a dark-mode OS doesn't render them as dark, misaligned dropdowns. */ + color-scheme: light; /* USWDS-ish tokens */ --primary: #005ea2; --primary-dark: #1a4480; @@ -22,9 +25,14 @@ --warning: #ffbe2e; --error: #d54309; - --radius: 8px; - --shadow: 0 1px 2px rgba(27, 27, 27, 0.08), 0 6px 18px rgba(27, 27, 27, 0.08); - --shadow-lg: 0 12px 40px rgba(27, 27, 27, 0.18); + --radius: 12px; + --radius-sm: 8px; + --radius-lg: 20px; + --radius-pill: 999px; + --field-border: #d3dae4; + --shadow-sm: 0 1px 2px rgba(27, 43, 60, 0.05); + --shadow: 0 1px 2px rgba(27, 43, 60, 0.04), 0 10px 28px rgba(27, 43, 60, 0.07); + --shadow-lg: 0 20px 55px rgba(27, 43, 60, 0.16); --font: "Source Sans Pro", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; } @@ -49,10 +57,10 @@ a { color: var(--primary); } display: flex; align-items: center; gap: 1.5rem; - padding: 0 1.25rem; + padding: 0 1.5rem; background: var(--primary-darker); color: var(--white); - height: 56px; + height: 60px; } .hub-brand { display: flex; align-items: baseline; gap: 0.35rem; font-weight: 700; } .hub-logo { @@ -92,18 +100,18 @@ a { color: var(--primary); } .hub-subtabs button:hover { color: var(--ink); } .hub-subtabs button.active { color: var(--primary); border-bottom-color: var(--primary); font-weight: 600; } -.hub-main { flex: 1; padding: 1.25rem; max-width: 1200px; width: 100%; margin: 0 auto; } +.hub-main { flex: 1; padding: 1.75rem 1.5rem; max-width: 1240px; width: 100%; margin: 0 auto; } /* ---------- Toolbar ---------- */ .toolbar { display: flex; gap: 0.6rem; align-items: center; flex-wrap: wrap; margin-bottom: 0.4rem; } .search { position: relative; display: flex; align-items: center; flex: 1 1 280px; min-width: 240px; - background: var(--white); border: 1px solid var(--base-light); border-radius: var(--radius); - padding: 0 0.6rem; height: 40px; + background: var(--white); border: 1px solid var(--field-border); border-radius: var(--radius); + padding: 0 0.75rem; height: 44px; } -.search:focus-within { border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-lighter); } -.search-ico { opacity: 0.5; font-size: 0.9rem; } -.search input { flex: 1; border: 0; outline: 0; font: inherit; font-size: 0.95rem; padding: 0 0.5rem; background: transparent; } +.search:focus-within { border-color: var(--primary); box-shadow: 0 0 0 4px var(--primary-lighter); } +.search-ico { opacity: 0.5; font-size: 0.95rem; } +.search input { flex: 1; border: 0; outline: 0; font: inherit; font-size: 1rem; padding: 0 0.5rem; background: transparent; } .search-x { border: 0; background: transparent; cursor: pointer; font-size: 1.1rem; color: var(--base); } .seg { display: inline-flex; background: var(--base-lightest); border-radius: var(--radius); padding: 3px; } @@ -114,19 +122,23 @@ a { color: var(--primary); } .seg button.active { background: var(--white); color: var(--ink); box-shadow: var(--shadow); font-weight: 600; } .btn { - border: 1px solid var(--base-light); background: var(--white); color: var(--ink); - font: inherit; font-size: 0.9rem; padding: 0.5rem 0.85rem; border-radius: var(--radius); - cursor: pointer; height: 40px; display: inline-flex; align-items: center; gap: 0.35rem; + border: 1px solid var(--field-border); background: var(--white); color: var(--ink); + font: inherit; font-size: 0.95rem; padding: 0.55rem 1rem; border-radius: var(--radius); + cursor: pointer; height: 44px; display: inline-flex; align-items: center; gap: 0.4rem; + transition: border-color 0.15s, background 0.15s, box-shadow 0.15s, transform 0.1s; } -.btn:hover { border-color: var(--base); } +.btn:hover { border-color: var(--base); box-shadow: var(--shadow-sm); } +.btn:active { transform: translateY(1px); } .btn.primary { background: var(--primary); border-color: var(--primary); color: var(--white); font-weight: 600; } -.btn.primary:hover { background: var(--primary-dark); } -.btn:disabled { opacity: 0.5; cursor: not-allowed; } +.btn.primary:hover { background: var(--primary-dark); border-color: var(--primary-dark); box-shadow: var(--shadow); } +.btn.danger { color: var(--error); border-color: var(--field-border); } +.btn.danger:hover { background: #fdecea; border-color: var(--error); box-shadow: none; } +.btn:disabled { opacity: 0.5; cursor: not-allowed; box-shadow: none; transform: none; } .add-btn { margin-left: auto; } .control { position: relative; } .popover { - position: absolute; z-index: 30; top: 44px; left: 0; min-width: 200px; max-height: 320px; + position: absolute; z-index: 30; top: 48px; left: 0; min-width: 200px; max-height: 320px; overflow: auto; padding: 0.4rem; } .popover .actions { display: flex; gap: 0.4rem; margin-bottom: 0.3rem; } @@ -247,31 +259,56 @@ a { color: var(--primary); } .team-badge { color: var(--white); font-size: 0.72rem; font-weight: 700; padding: 0.12rem 0.45rem; border-radius: 4px; } .popover-card .mdetail { border-top: 0; padding: 0 0.9rem 0.9rem; } -.modal { max-width: 620px; width: 100%; padding: 1.1rem 1.2rem; max-height: 88vh; overflow: auto; } -.modal-head { display: flex; align-items: center; justify-content: space-between; } -.modal-head h2 { margin: 0; font-size: 1.2rem; } -.modal-x { border: 0; background: transparent; font-size: 1.5rem; line-height: 1; cursor: pointer; color: var(--base); } -.hint { font-size: 0.85rem; color: var(--base); background: var(--base-lightest); padding: 0.55rem 0.7rem; border-radius: 6px; margin: 0.5rem 0 0.9rem; } +.modal { max-width: 720px; width: 100%; padding: 1.75rem 1.9rem 1.6rem; max-height: 90vh; overflow: auto; border-radius: var(--radius-lg); } +.modal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.4rem; } +.modal-head h2 { margin: 0; font-size: 1.55rem; letter-spacing: -0.01em; } +.modal-x { border: 0; background: transparent; font-size: 1.6rem; line-height: 1; cursor: pointer; color: var(--base-light); width: 38px; height: 38px; border-radius: var(--radius-pill); display: inline-flex; align-items: center; justify-content: center; transition: background 0.15s, color 0.15s; } +.modal-x:hover { background: var(--base-lightest); color: var(--ink); } +.hint { font-size: 0.95rem; line-height: 1.5; color: var(--base-dark); background: var(--primary-lighter); border: 1px solid #cfe1f4; padding: 0.8rem 1rem; border-radius: var(--radius); margin: 0.4rem 0 1.4rem; } .hint code, .jsonlabel code { background: var(--white); padding: 0.05rem 0.3rem; border-radius: 4px; border: 1px solid var(--base-lighter); font-size: 0.85em; } -.field { margin-bottom: 0.7rem; } -.field.two { display: grid; grid-template-columns: 1fr 1fr; gap: 0.7rem; } -.field label { display: block; font-size: 0.8rem; font-weight: 600; color: var(--base-dark); margin-bottom: 0.2rem; } +.field { margin-bottom: 1.05rem; } +.field.two { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } +.field label { display: block; font-size: 0.88rem; font-weight: 600; color: var(--base-dark); margin-bottom: 0.35rem; } .field .opt { color: var(--base-light); font-weight: 400; } .field input, .field select, .field textarea { - width: 100%; font: inherit; font-size: 0.9rem; padding: 0.45rem 0.55rem; - border: 1px solid var(--base-light); border-radius: 6px; background: var(--white); -} -.field input:focus, .field select:focus, .field textarea:focus { outline: 0; border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-lighter); } + width: 100%; font: inherit; font-size: 1rem; padding: 0.7rem 0.8rem; min-height: 46px; + border: 1px solid var(--field-border); border-radius: var(--radius); background: #fcfdfe; + color: var(--ink); transition: border-color 0.15s, box-shadow 0.15s, background 0.15s; +} +.field textarea { min-height: 72px; line-height: 1.5; } +.field input::placeholder, .field textarea::placeholder { color: var(--base-light); } +.field input:hover, .field select:hover, .field textarea:hover { border-color: var(--base-light); } +.field input:focus, .field select:focus, .field textarea:focus { outline: 0; border-color: var(--primary); background: var(--white); box-shadow: 0 0 0 4px var(--primary-lighter); } .times { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; } .times-dash { color: var(--base); font-size: 1rem; line-height: 1; flex-shrink: 0; } +/* ---------- Combobox (custom light autocomplete; replaces native ) ---------- */ +.combo { position: relative; } +.combo .combo-input { padding-right: 2.1rem; } +.combo-caret { + position: absolute; right: 0.5rem; top: 50%; transform: translateY(-50%); + border: 0; background: transparent; color: var(--base); font-size: 0.7rem; line-height: 1; + cursor: pointer; padding: 0.3rem; border-radius: var(--radius-sm); +} +.combo-caret:hover { color: var(--primary); } +.combo-menu { + position: absolute; z-index: 45; top: calc(100% + 4px); left: 0; right: 0; + max-height: 260px; overflow-y: auto; padding: 0.3rem; display: flex; flex-direction: column; gap: 1px; +} +.combo-option { + width: 100%; text-align: left; background: transparent; border: 0; border-radius: var(--radius-sm); + font: inherit; font-size: 0.95rem; padding: 0.5rem 0.6rem; cursor: pointer; color: var(--ink); +} +.combo-option:hover, .combo-option.active { background: var(--primary-lighter); color: var(--primary-darker); } +.combo-option.selected { font-weight: 600; } + /* ---------- Pill time picker ---------- */ .tp-wrap { position: relative; display: inline-block; } .tp-pill { - border: 1px solid var(--base-light); background: var(--white); color: var(--base); - font: inherit; font-size: 0.88rem; padding: 0.35rem 0.65rem; border-radius: 20px; - cursor: pointer; white-space: nowrap; + border: 1px solid var(--field-border); background: var(--white); color: var(--base); + font: inherit; font-size: 0.95rem; padding: 0.55rem 0.9rem; border-radius: var(--radius-pill); + cursor: pointer; white-space: nowrap; transition: border-color 0.15s, background 0.15s; } .tp-pill.has-value { color: var(--ink); border-color: var(--primary); background: var(--primary-lighter); font-weight: 600; } .tp-pill:hover { border-color: var(--primary); } @@ -288,17 +325,18 @@ a { color: var(--primary); } .tp-option.selected { background: var(--primary); color: var(--white); font-weight: 600; } .tp-clear { color: var(--base); font-size: 0.8rem; border-bottom: 1px solid var(--base-lighter); margin-bottom: 0.2rem; padding-bottom: 0.35rem; } -.sched { border: 1px solid var(--base-lighter); border-radius: var(--radius); padding: 0.7rem 0.8rem 0.4rem; margin-bottom: 0.9rem; } -.sched legend { font-size: 0.82rem; font-weight: 700; color: var(--primary-dark); padding: 0 0.3rem; } +.sched { border: 1px solid var(--base-lighter); border-radius: var(--radius); padding: 1rem 1.1rem 0.5rem; margin-bottom: 1.3rem; background: #f8fafc; } +.sched legend { font-size: 0.9rem; font-weight: 700; color: var(--primary-dark); padding: 0.15rem 0.65rem; background: var(--white); border: 1px solid var(--base-lighter); border-radius: var(--radius-pill); } .daypicker { display: flex; gap: 0.3rem; flex-wrap: wrap; } -.daypicker .day { border: 1px solid var(--base-light); background: var(--white); border-radius: 6px; padding: 0.35rem 0.55rem; font: inherit; font-size: 0.82rem; cursor: pointer; } +.daypicker .day { border: 1px solid var(--field-border); background: var(--white); border-radius: var(--radius-sm); padding: 0.5rem 0.75rem; font: inherit; font-size: 0.9rem; cursor: pointer; transition: border-color 0.15s, background 0.15s; } +.daypicker .day:hover { border-color: var(--primary); } .daypicker .day.on { background: var(--primary); border-color: var(--primary); color: var(--white); font-weight: 600; } -.jsonlabel { display: block; font-size: 0.78rem; color: var(--base); margin-bottom: 0.25rem; } -.urlbox { background: #0f1b2d; color: #cfe2ff; border-radius: 6px; padding: 0.6rem 0.7rem; font-size: 0.75rem; max-height: 160px; overflow: auto; margin: 0 0 0.9rem; white-space: pre-wrap; word-break: break-word; } +.jsonlabel { display: block; font-size: 0.82rem; color: var(--base); margin-bottom: 0.3rem; } +.urlbox { background: #0f1b2d; color: #cfe2ff; border-radius: var(--radius-sm); padding: 0.75rem 0.9rem; font-size: 0.78rem; max-height: 170px; overflow: auto; margin: 0 0 1.1rem; white-space: pre-wrap; word-break: break-word; } -.foot { display: flex; gap: 0.5rem; justify-content: flex-end; flex-wrap: wrap; } +.foot { display: flex; gap: 0.6rem; justify-content: flex-end; flex-wrap: wrap; margin-top: 0.4rem; } .foot .primary { text-decoration: none; } /* ---------- Calendar reMINDer-style additions ---------- */ @@ -431,8 +469,18 @@ a { color: var(--primary); } .row-sprint { padding-left: 1.3rem; } /* Edit button inside the shared meeting detail */ -.mdetail-actions { margin-top: 0.6rem; display: flex; justify-content: flex-end; } -.mdetail-actions .btn { height: 32px; font-size: 0.82rem; padding: 0.3rem 0.7rem; } +.mdetail-actions { margin-top: 0.7rem; display: flex; justify-content: flex-end; gap: 0.5rem; } +.mdetail-actions .btn { height: 34px; font-size: 0.85rem; padding: 0.3rem 0.75rem; } + +/* Press-and-hold delete: a red fill sweeps across in `--hold-ms`; releasing snaps it back to 0. */ +.hold-btn { position: relative; overflow: hidden; touch-action: none; user-select: none; } +.hold-btn .hold-fill { + position: absolute; left: 0; top: 0; bottom: 0; width: 0; + background: var(--error); opacity: 0.22; transition: width 0s linear; +} +.hold-btn.holding { border-color: var(--error); } +.hold-btn.holding .hold-fill { width: 100%; transition: width var(--hold-ms, 2000ms) linear; } +.hold-btn .hold-label { position: relative; z-index: 1; } /* ============ Changes cart ============ */ .changes-btn {