Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 14 additions & 9 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,15 +157,20 @@ No browser restart recovery of these uncommitted forms is promised. JavaScript
strings cannot be guaranteed to have been physically erased from process memory.
Exports are deliberate readable files with manual-recovery limits.

The preparatory Journal habit-detail selector derives streak scalars from validated
civil completion dates without persisting a streak or retaining source objects.
A checked selected day ends its consecutive run there; an unchecked day may retain
the run ending exactly one day earlier. Creation/archive bounds are inclusive,
and missing or undone dates stop the run. UTC calendar ordinals avoid daylight-
saving arithmetic across supported years 1000..9999. It indexes completion history
once and returns fresh scalar projections. The dependent Journal control slice
owns its unlocked-view lifecycle and visible feedback; this helper alone changes
no application screen, key, envelope or storage format.
Habit streak details are derived inside the unlocked Journal view from real civil
completion dates. A checked selected day ends its run there; an unchecked day may
retain the consecutive run ending exactly one day earlier. Creation and archival
bounds apply, and a missing or undone date breaks the run. UTC calendar ordinals
avoid daylight-saving arithmetic; no streak is persisted or shared with Study's
day-start preference. Descriptions identify the actual through-date. Count feedback
retains only outgoing numeric scalars within the unlocked subtree and disappears
synchronously when that private owner closes.

The calendar shades absolute completed counts: zero, one or two, three, and four
or more. It retains exact historical completed/eligible totals. The last legend
label becomes `4+` only when a displayed actual count exceeds four. Feedback uses
the vault's protected draft, including a retained draft after a failed write;
durable Saved status still requires the existing encrypted commit acknowledgement.

Clips stores validated HTTP(S) links and optional actual pasted text. It does not
fetch a page as part of local Save link. Duplicate comparison uses a separate
Expand Down
155 changes: 148 additions & 7 deletions web/src/styles/journal.css
Original file line number Diff line number Diff line change
Expand Up @@ -148,21 +148,162 @@
.journal-habits > button[aria-pressed="true"] {
color: var(--t1);
}
.journal-view .journal-habits > button:active {
transform: none;
}
.journal-habits > button:focus-visible {
outline: 2px solid var(--t1);
outline-offset: 2px;
}
.journal-check {
position: relative;
display: inline-grid;
place-items: center;
flex: 0 0 14px;
width: 14px;
height: 14px;
border: 1px solid var(--line2);
border-radius: 4px;
font-size: 10px;
line-height: 1;
flex: 0 0 15px;
width: 15px;
height: 15px;
border: 1.5px solid var(--line2);
border-radius: 5px;
transform: scale(1);
transition:
background-color 150ms cubic-bezier(0.4, 0, 0.2, 1),
border-color 150ms cubic-bezier(0.4, 0, 0.2, 1),
transform 150ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.journal-habits > button:not(:disabled):active .journal-check {
transform: scale(0.92);
}
.journal-check-mark {
position: absolute;
left: 4px;
top: 1px;
width: 4px;
height: 8px;
border: solid var(--main);
border-width: 0 2px 2px 0;
transform: rotate(45deg);
opacity: 0;
transition: opacity 150ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.journal-habits > button[aria-pressed="true"] .journal-check {
background: var(--t1);
border-color: var(--t1);
color: var(--main);
}
.journal-habits > button[aria-pressed="true"] .journal-check-mark {
opacity: 1;
}
.journal-habit-streak {
display: inline-grid;
color: var(--t3);
font-size: 11.5px;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.journal-habit-count {
grid-area: 1 / 1;
align-self: center;
}
.journal-habit-count[data-entering] {
animation: journal-streak-in 150ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
.journal-habit-count[data-leaving] {
animation: journal-streak-out 105ms cubic-bezier(0.4, 0, 0.2, 1) both;
}
@keyframes journal-streak-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes journal-streak-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.journal-sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.journal-legend {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 10px 0;
}
.journal-legend-swatches {
display: inline-grid;
grid-template-columns: repeat(4, 14px);
gap: 3px;
}
.journal-legend-swatches i {
width: 14px;
height: 14px;
border-radius: 3px;
background: var(--raise);
}
.journal-legend-swatches i[data-level="1"] {
background: var(--line2);
}
.journal-legend-swatches i[data-level="2"] {
background: var(--t3);
}
.journal-legend-swatches i[data-level="3"] {
background: var(--t1);
}
@media (max-width: 499px) {
.journal-habit-streak {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
:root:not([data-motion="on"]) .journal-view .journal-check {
transform: none !important;
transition:
background-color 150ms cubic-bezier(0.4, 0, 0.2, 1),
border-color 150ms cubic-bezier(0.4, 0, 0.2, 1) !important;
}
:root:not([data-motion="on"]) .journal-view .journal-check-mark {
transition: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1) !important;
}
:root:not([data-motion="on"])
.journal-view
.journal-habit-count[data-entering] {
animation: journal-streak-in 150ms cubic-bezier(0.4, 0, 0.2, 1) both !important;
}
:root:not([data-motion="on"])
.journal-view
.journal-habit-count[data-leaving] {
animation: journal-streak-out 150ms cubic-bezier(0.4, 0, 0.2, 1) both !important;
}
}
:root[data-motion="reduced"] .journal-view .journal-check {
transform: none !important;
transition:
background-color 150ms cubic-bezier(0.4, 0, 0.2, 1),
border-color 150ms cubic-bezier(0.4, 0, 0.2, 1) !important;
}
:root[data-motion="reduced"] .journal-view .journal-check-mark {
transition: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1) !important;
}
:root[data-motion="reduced"] .journal-view .journal-habit-count[data-entering] {
animation: journal-streak-in 150ms cubic-bezier(0.4, 0, 0.2, 1) both !important;
}
:root[data-motion="reduced"] .journal-view .journal-habit-count[data-leaving] {
animation: journal-streak-out 150ms cubic-bezier(0.4, 0, 0.2, 1) both !important;
}
.journal-month-panel {
border-left: 1px solid var(--line);
padding: 14px;
Expand Down
111 changes: 111 additions & 0 deletions web/src/ui/JournalHabitFeedback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import type { AnimationEvent } from "react";
import type { JournalHabitDetail } from "./journalHabitDetails";

/** Owned by the unlocked Journal tree. Presence retains numeric scalars only. */
export function JournalHabitFeedback({
name,
detail,
throughLabel,
disabled,
onToggle,
}: {
name: string;
detail: JournalHabitDetail;
throughLabel: string | null;
disabled: boolean;
onToggle: () => void;
}) {
const description = useId();
const count = useRef(detail.days),
generation = useRef(0),
alive = useRef(true);
const [presence, setPresence] = useState<{
outgoing: number | null;
entering: boolean;
nonce: number;
}>({ outgoing: null, entering: false, nonce: 0 });
useLayoutEffect(() => {
alive.current = true;
return () => {
alive.current = false;
generation.current++;
};
}, []);
useLayoutEffect(() => {
if (count.current === detail.days) return;
const outgoing = count.current;
count.current = detail.days;
setPresence({ outgoing, entering: true, nonce: ++generation.current });
}, [detail.days]);
useEffect(() => {
if (!presence.nonce) return;
const nonce = presence.nonce;
// Normal exit is105ms; effective reduced exit/entry is150ms. This
// presentation-only fallback also covers a policy change mid-transition.
const timer = setTimeout(() => {
if (alive.current && generation.current === nonce)
setPresence((current) =>
current.outgoing === null && !current.entering
? current
: { ...current, outgoing: null, entering: false },
);
}, 150 + 34);
return () => clearTimeout(timer);
}, [presence.nonce]);
function finish(event: AnimationEvent<HTMLSpanElement>, leaving: boolean) {
if (
event.target !== event.currentTarget ||
!alive.current ||
generation.current !== presence.nonce ||
event.animationName !==
(leaving ? "journal-streak-out" : "journal-streak-in")
)
return;
setPresence((current) =>
leaving
? { ...current, outgoing: null }
: { ...current, entering: false },
);
}
return (
<button
type="button"
aria-label={name}
aria-describedby={description}
aria-pressed={detail.completed}
disabled={disabled}
onClick={onToggle}
>
<span className="journal-check" aria-hidden="true">
<span className="journal-check-mark" />
</span>
<span>{name}</span>
<small className="journal-habit-streak" aria-hidden="true">
{presence.outgoing !== null && (
<span
key={`out-${presence.nonce}`}
className="journal-habit-count"
data-leaving="true"
onAnimationEnd={(event) => finish(event, true)}
>
{presence.outgoing} {presence.outgoing === 1 ? "day" : "days"}
</span>
)}
<span
key={presence.nonce}
className="journal-habit-count"
data-entering={presence.entering || undefined}
onAnimationEnd={(event) => finish(event, false)}
>
{detail.days} {detail.days === 1 ? "day" : "days"}
</span>
</small>
<span id={description} className="journal-sr-only">
{detail.days === 0
? "0 day streak."
: `${detail.days} consecutive ${detail.days === 1 ? "day" : "days"}${throughLabel ? ` through ${throughLabel}` : ""}.`}
</span>
</button>
);
}
Loading
Loading