@lotics/ui 16.2.0 → 17.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -32,7 +32,7 @@ import { CheckCircle } from "@lotics/ui/check_circle";
32
32
  import { ProgressBar } from "@lotics/ui/progress_bar";
33
33
  import { FilterChip } from "@lotics/ui/filter_chip";
34
34
  import { CaptureRow } from "@lotics/ui/capture_row";
35
- import { Checklist, ChecklistRow } from "@lotics/ui/checklist";
35
+ import { TaskActions, TaskDetail, TaskFields, TaskItem, TaskList, TaskStatus, TaskTitle } from "@lotics/ui/task";
36
36
  import { ActionMenu, type ActionMenuItem } from "@lotics/ui/action_menu";
37
37
  import { SuggestionChip } from "@lotics/ui/suggestion_chip";
38
38
  import { OptionList } from "@lotics/ui/option_list";
@@ -165,15 +165,9 @@ interface StageTask {
165
165
  /** ISO date (`""` = no due). The row shows it in a `variant="cell"` date field —
166
166
  * no calendar glyph, coloured by urgency (`tone`), press to edit. */
167
167
  due: string;
168
- /** The task's own note (`""` = none). The `ChecklistRow` `note` contract owns
169
- * the whole affordance the muted second line, the tap-to-edit, the add
170
- * button on a note-less row, and removal by saving empty. */
171
- note: string;
172
- /** The task's CHILD STEPS (`[]` = none, and then nothing renders — not even
173
- * the expander). The `ChecklistRow` `subtasks` contract owns the whole
174
- * affordance: the chevron + remaining count on the parent, the open/closed
175
- * state, and the indented child rows. A step that needs its own note, due
176
- * date, or assignee is a TASK — give it a row, not a subtask. */
168
+ /** The task's CHILD STEPS (`[]` = none). They render as a NESTED `TaskList`,
169
+ * so a step is a task: give one a due date, a menu or children of its own and
170
+ * it simply works. Collapsing is the app's call render the list or don't. */
177
171
  subtasks: { key: string; label: string; done: boolean }[];
178
172
  }
179
173
 
@@ -213,23 +207,23 @@ const newTaskId = () => `t_${(taskSeq += 1)}`;
213
207
 
214
208
  // The in-flight demo record's existing checklist (a NEW record gets []).
215
209
  const TASK_SEEDS: StageTask[] = [
216
- { id: "t1", label: "Confirm pricing with the customer", stage: "sales", done: true, assignee: "mem_01", due: dueIn(-10), note: "", subtasks: [] },
217
- { id: "t2", label: "Attach the signed quote", stage: "sales", done: true, assignee: "mem_01", due: "", note: "", subtasks: [] },
210
+ { id: "t1", label: "Confirm pricing with the customer", stage: "sales", done: true, assignee: "mem_01", due: dueIn(-10), subtasks: [] },
211
+ { id: "t2", label: "Attach the signed quote", stage: "sales", done: true, assignee: "mem_01", due: "", subtasks: [] },
218
212
  // two seeded NOTES — the "why it's stuck" a task carries and nothing else
219
213
  // holds; every other row shows the add affordance instead
220
- { id: "t3", label: "Verify the customer's tax ID", stage: "sales", done: false, assignee: null, due: dueIn(-2), note: "Their finance desk answers after 15:00 only.", subtasks: [] },
214
+ { id: "t3", label: "Verify the customer's tax ID", stage: "sales", done: false, assignee: null, due: dueIn(-2), subtasks: [] },
221
215
  // the one task with SUBTASKS — a booking is a sequence, and its steps carry
222
216
  // nothing but a tick, so they stay child rows instead of four more tasks
223
217
  // (the row opens with work left; press the chevron to close it)
224
- { id: "t4", label: "Book the carrier", stage: "operations", done: false, assignee: "mem_02", due: dueIn(1), note: "", subtasks: [
218
+ { id: "t4", label: "Book the carrier", stage: "operations", done: false, assignee: "mem_02", due: dueIn(1), subtasks: [
225
219
  { key: "s1", label: "Confirm the pickup window", done: true },
226
220
  { key: "s2", label: "Send the packing list", done: false },
227
221
  { key: "s3", label: "Get the booking reference", done: false },
228
222
  { key: "s4", label: "Share the reference with the customer", done: false },
229
223
  ] },
230
- { id: "t5", label: "Attach the delivery documents", stage: "operations", done: false, assignee: null, due: dueIn(6), note: "Waiting on the signed copy from the yard.", subtasks: [] },
231
- { id: "t6", label: "Issue every invoice", stage: "accounting", done: false, assignee: "mem_03", due: "", note: "", subtasks: [] },
232
- { id: "t7", label: "Reconcile the receipts", stage: "accounting", done: false, assignee: null, due: dueIn(14), note: "", subtasks: [] },
224
+ { id: "t5", label: "Attach the delivery documents", stage: "operations", done: false, assignee: null, due: dueIn(6), subtasks: [] },
225
+ { id: "t6", label: "Issue every invoice", stage: "accounting", done: false, assignee: "mem_03", due: "", subtasks: [] },
226
+ { id: "t7", label: "Reconcile the receipts", stage: "accounting", done: false, assignee: null, due: dueIn(14), subtasks: [] },
233
227
  ];
234
228
 
235
229
  // The assignable roster — an app feeds `useMembers()` here.
@@ -1158,11 +1152,11 @@ export function TplRecord() {
1158
1152
  const addTask = () => {
1159
1153
  const label = newTask.trim();
1160
1154
  if (!label) return;
1161
- setTasks((prev) => [...prev, { id: newTaskId(), label, stage: captureDesk, done: false, assignee: null, due: "", note: "", subtasks: [] }]);
1155
+ setTasks((prev) => [...prev, { id: newTaskId(), label, stage: captureDesk, done: false, assignee: null, due: "", subtasks: [] }]);
1162
1156
  setNewTask("");
1163
1157
  };
1164
1158
  const addSuggested = (desk: Desk, label: string) =>
1165
- setTasks((prev) => [...prev, { id: newTaskId(), label, stage: desk, done: false, assignee: null, due: "", note: "", subtasks: [] }]);
1159
+ setTasks((prev) => [...prev, { id: newTaskId(), label, stage: desk, done: false, assignee: null, due: "", subtasks: [] }]);
1166
1160
  const removeTask = (tid: string) => setTasks((prev) => prev.filter((t) => t.id !== tid));
1167
1161
  const moveTask = (tid: string, desk: Desk) =>
1168
1162
  setTasks((prev) => prev.map((t) => (t.id === tid ? { ...t, stage: desk } : t)));
@@ -1173,9 +1167,6 @@ export function TplRecord() {
1173
1167
  setTasks((prev) => prev.map((t) => (t.id === tid ? { ...t, assignee: member } : t)));
1174
1168
  const dueTask = (tid: string, due: string) =>
1175
1169
  setTasks((prev) => prev.map((t) => (t.id === tid ? { ...t, due } : t)));
1176
- // Saving an EMPTY note is the removal — one write path, no separate delete.
1177
- const noteTask = (tid: string, note: string) =>
1178
- setTasks((prev) => prev.map((t) => (t.id === tid ? { ...t, note } : t)));
1179
1170
  // A child step patches by key — the kit owns the expander and the count, the
1180
1171
  // app owns nothing but the tick.
1181
1172
  const toggleSubtask = (tid: string, key: string, done: boolean) =>
@@ -1864,7 +1855,7 @@ export function TplRecord() {
1864
1855
  desk's rows fully editable (the stage gates the handoff
1865
1856
  and Billing, never task editing — planning ahead on a
1866
1857
  later desk is normal work) */}
1867
- <Checklist trailingWidth={152}>
1858
+ <TaskList>
1868
1859
  {g.items.map((t) => {
1869
1860
  const next = nextDeskOf(t.stage);
1870
1861
  const menuItems: ActionMenuItem[] = [
@@ -1874,54 +1865,48 @@ export function TplRecord() {
1874
1865
  { key: "xoa", label: "Delete task", icon: "trash", danger: true, onPress: () => removeTask(t.id) },
1875
1866
  ];
1876
1867
  return (
1877
- <ChecklistRow
1878
- key={t.id}
1879
- control={
1868
+ <TaskItem key={t.id}>
1869
+ <TaskStatus>
1870
+ {/* Half-filled once any subtask is in: progress the row already
1871
+ holds, that a binary ring would have hidden. Display only —
1872
+ the parent's own `done` still wins, and still only it. */}
1880
1873
  <CheckCircle
1881
- checked={t.done}
1874
+ state={t.done ? "done" : t.subtasks.some((s) => s.done) ? "partial" : "none"}
1882
1875
  onChange={(on) => toggleTask(t.id, on)}
1883
1876
  accessibilityLabel={t.label}
1884
1877
  />
1885
- }
1886
- trailing={
1887
- // Due + assignee as COMPACT grid cells (`variant="cell"`): the due a
1888
- // urgency-coloured date (no calendar glyph), the assignee a bare AVATAR
1889
- // (`avatarOnly` no name/chevron, a dashed add-ghost when unset). Both
1890
- // hug within the list's `trailingWidth` so the title never overlaps.
1891
- <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "flex-end", gap: 8 }}>
1892
- <View style={{ width: 104 }}>
1893
- <InlineDatePicker variant="cell" tone={dueTone(t.due, t.done)} value={t.due || null} onSave={(v) => dueTask(t.id, v)} onClear={() => dueTask(t.id, "")} placeholder="Due…" locale="en-US" accessibilityLabel={`Due · ${t.label}`} />
1894
- </View>
1895
- <InlineMemberSelect variant="cell" avatarOnly members={TEAM} value={t.assignee} onSave={(m) => assignTask(t.id, m)} accessibilityLabel={`Assignee · ${t.label}`} />
1878
+ </TaskStatus>
1879
+ <TaskTitle>
1880
+ <InlineTextInput variant="cell" value={t.label} onSave={(v) => renameTask(t.id, v)} struck={t.done} accessibilityLabel="Task title" />
1881
+ </TaskTitle>
1882
+ {/* Due + assignee as COMPACT grid cells (`variant="cell"`): the due an
1883
+ urgency-coloured date (no calendar glyph), the assignee a bare AVATAR
1884
+ (`avatarOnly`). They sit inline on a wide record and take their own
1885
+ line in a drawer — the SAME JSX, no surface guess. */}
1886
+ <TaskFields>
1887
+ <View style={{ width: 104 }}>
1888
+ <InlineDatePicker variant="cell" tone={dueTone(t.due, t.done)} value={t.due || null} onSave={(v) => dueTask(t.id, v)} onClear={() => dueTask(t.id, "")} placeholder="Due…" locale="en-US" accessibilityLabel={`Due · ${t.label}`} />
1896
1889
  </View>
1897
- }
1898
- // the row's own NOTE — the muted second line under the
1899
- // title (the "why it's stuck" a task carries); the kit
1900
- // owns the whole affordance, so nothing here hand-rolls
1901
- // a line + editor. Empty = the add button on the row.
1902
- note={{ value: t.note, onSave: (v) => noteTask(t.id, v), addLabel: `Add note: ${t.label}` }}
1903
- // the row's CHILD STEPS — one task made of several
1904
- // ticks. The kit renders the chevron + remaining count
1905
- // on the parent and the indented child rows when open;
1906
- // an EMPTY list renders nothing, so every row can pass
1907
- // the prop unconditionally. Expansion is left
1908
- // UNCONTROLLED: a row with work left opens itself.
1909
- subtasks={{
1910
- items: t.subtasks.map((s) => ({ key: s.key, label: s.label, checked: s.done, onToggle: (on) => toggleSubtask(t.id, s.key, on) })),
1911
- // the row name matters once the list is long — the
1912
- // locale default ("Subtasks: 3 left") can't know it
1913
- countLabel: (missing) => (missing > 0 ? `Subtasks · ${t.label}: ${missing} left` : `Subtasks · ${t.label}: all done`),
1914
- }}
1915
- menu={{ items: menuItems, accessibilityLabel: `Task options: ${t.label}` }}
1916
- >
1917
- <InlineTextInput
1918
- variant="cell"
1919
- value={t.label}
1920
- onSave={(v) => renameTask(t.id, v)}
1921
- struck={t.done}
1922
- accessibilityLabel="Task title"
1923
- />
1924
- </ChecklistRow>
1890
+ <InlineMemberSelect variant="cell" avatarOnly members={TEAM} value={t.assignee} onSave={(m) => assignTask(t.id, m)} accessibilityLabel={`Assignee · ${t.label}`} />
1891
+ </TaskFields>
1892
+ <TaskActions>
1893
+ <ActionMenu items={menuItems} accessibilityLabel={`Task options: ${t.label}`} />
1894
+ </TaskActions>
1895
+ {t.subtasks.length > 0 ? (
1896
+ <TaskList>
1897
+ {t.subtasks.map((sub) => (
1898
+ <TaskItem key={sub.key}>
1899
+ <TaskStatus>
1900
+ <CheckCircle state={sub.done ? "done" : "none"} onChange={(on) => toggleSubtask(t.id, sub.key, on)} accessibilityLabel={sub.label} />
1901
+ </TaskStatus>
1902
+ <TaskTitle>
1903
+ <Text size="sm" color={sub.done ? "muted" : "default"} decoration={sub.done ? "lineThrough" : undefined}>{sub.label}</Text>
1904
+ </TaskTitle>
1905
+ </TaskItem>
1906
+ ))}
1907
+ </TaskList>
1908
+ ) : null}
1909
+ </TaskItem>
1925
1910
  );
1926
1911
  })}
1927
1912
  {/* SUGGESTIONS are pills, not rows — tap to materialize,
@@ -1938,7 +1923,7 @@ export function TplRecord() {
1938
1923
  ))}
1939
1924
  </View>
1940
1925
  ) : null}
1941
- </Checklist>
1926
+ </TaskList>
1942
1927
  </Subsection>
1943
1928
  );
1944
1929
  })}
@@ -2259,7 +2244,7 @@ export function TplRecord() {
2259
2244
  the geometry (row height, control/title alignment, the
2260
2245
  meta/expansion indent); hand-rolling this anatomy is how
2261
2246
  alignment drifts. controlWidth 24 = CheckboxInput. */}
2262
- <Checklist controlWidth={24}>
2247
+ <TaskList controlWidth={24}>
2263
2248
  {shown.map((f) => {
2264
2249
  // READINESS per row, CO-LOCATED: a not-ready row's mark NAMES what's
2265
2250
  // missing (muted at rest — discoverable without checking; warning once
@@ -2271,16 +2256,23 @@ export function TplRecord() {
2271
2256
  const missing = missingOf(f);
2272
2257
  const checked = chosenForms.has(f.id);
2273
2258
  return (
2274
- <ChecklistRow
2275
- key={f.id}
2276
- control={<CheckboxInput accessibilityLabel={f.label} checked={checked} onChange={(on) => toggleForm(f.id, on)} />}
2277
- meta={missing.length > 0 ? (
2259
+ <TaskItem key={f.id}>
2260
+ <TaskStatus>
2261
+ <CheckboxInput accessibilityLabel={f.label} checked={checked} onChange={(on) => toggleForm(f.id, on)} />
2262
+ </TaskStatus>
2263
+ <TaskTitle>
2264
+ <Text size="sm" style={{ flexShrink: 1 }}>{f.label}</Text>
2265
+ </TaskTitle>
2266
+ {missing.length > 0 ? (
2267
+ <TaskFields>
2278
2268
  <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 5 }}>
2279
2269
  <Icon name="circle-alert" size={13} color={checked ? colors.amber[500] : colors.zinc[400]} />
2280
2270
  <Text size="xs" color={checked ? "warning" : "muted"} style={{ flexShrink: 1 }}>{`Needs: ${missing.map((k) => NEEDS[k].label).join(", ")}`}</Text>
2281
2271
  </View>
2282
- ) : undefined}
2283
- expansion={checked && missing.length > 0 ? (
2272
+ </TaskFields>
2273
+ ) : null}
2274
+ {checked && missing.length > 0 ? (
2275
+ <TaskDetail>
2284
2276
  <Inset>
2285
2277
  <Text size="xs" color="muted">These values save onto the order and unlock the form.</Text>
2286
2278
  {missing.map((k) => (
@@ -2302,13 +2294,12 @@ export function TplRecord() {
2302
2294
  />
2303
2295
  </View>
2304
2296
  </Inset>
2305
- ) : undefined}
2306
- >
2307
- <Text size="sm" style={{ flexShrink: 1 }}>{f.label}</Text>
2308
- </ChecklistRow>
2297
+ </TaskDetail>
2298
+ ) : null}
2299
+ </TaskItem>
2309
2300
  );
2310
2301
  })}
2311
- </Checklist>
2302
+ </TaskList>
2312
2303
  {hidden > 0 ? (
2313
2304
  <View style={{ flexDirection: "row" }}>
2314
2305
  <Button
@@ -366,7 +366,13 @@ export function TplTaskBoard() {
366
366
  columns={columns}
367
367
  groups={groups}
368
368
  getRowKey={(t) => t.id}
369
- leading={{ width: 20, render: (t) => <CheckCircle checked={t.status === "done"} onChange={(on) => patch(t.id, { status: on ? "done" : "todo" })} accessibilityLabel={t.title} /> }}
369
+ leading={{
370
+ width: 20,
371
+ // The ring carries all three positions this board already models: a task at "doing"
372
+ // is not finished and is not untouched, and a binary ring would call it untouched.
373
+ // Clicking still only finishes or reopens — "doing" is set from the status column.
374
+ render: (t) => <CheckCircle state={t.status === "done" ? "done" : t.status === "doing" ? "partial" : "none"} onChange={(on) => patch(t.id, { status: on ? "done" : "todo" })} accessibilityLabel={t.title} />,
375
+ }}
370
376
  sort={sort}
371
377
  onSort={onSort}
372
378
  collapsed={collapsed}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "16.2.0",
3
+ "version": "17.0.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -182,7 +182,7 @@
182
182
  "./detail_row": "./src/detail_row.tsx",
183
183
  "./record_summary": "./src/record_summary.tsx",
184
184
  "./capture_row": "./src/capture_row.tsx",
185
- "./checklist": "./src/checklist.tsx",
185
+ "./task": "./src/task.tsx",
186
186
  "./suggestion_chip": "./src/suggestion_chip.tsx",
187
187
  "./danger_zone": "./src/danger_zone.tsx",
188
188
  "./scan_field": "./src/scan_field.tsx",
@@ -314,4 +314,4 @@
314
314
  "unified": "^11.0.5",
315
315
  "vite": "^7.2.4"
316
316
  }
317
- }
317
+ }
@@ -1,6 +1,7 @@
1
1
  import { StyleSheet, View } from "react-native";
2
2
  import { Button } from "./button";
3
3
  import { colors } from "./colors";
4
+ import { Icon } from "./icon";
4
5
  import { useLoticsLocale } from "./locale";
5
6
  import { TextInputField } from "./text_input_field";
6
7
 
@@ -30,7 +31,11 @@ export function CaptureRow(props: CaptureRowProps) {
30
31
  const labels = useLoticsLocale().inline;
31
32
  return (
32
33
  <View style={styles.row}>
33
- <View style={styles.emptyRing} />
34
+ {/* The ghost ring holds the task column, and the plus says what pressing here does —
35
+ a dashed circle alone reads as an unchecked task, not as an invitation. */}
36
+ <View style={styles.emptyRing}>
37
+ <Icon name="plus" size={12} color={colors.zinc[400]} />
38
+ </View>
34
39
  <View style={styles.field}>
35
40
  <View style={styles.grow}>
36
41
  <TextInputField
@@ -50,7 +55,7 @@ export function CaptureRow(props: CaptureRowProps) {
50
55
 
51
56
  const styles = StyleSheet.create({
52
57
  row: { flexDirection: "row", alignItems: "center", gap: 12, minHeight: 32 },
53
- emptyRing: { width: 20, height: 20, borderRadius: 10, borderWidth: 1.5, borderColor: colors.zinc[300], borderStyle: "dashed" },
58
+ emptyRing: { width: 20, height: 20, borderRadius: 10, borderWidth: 1.5, borderColor: colors.zinc[300], borderStyle: "dashed", alignItems: "center", justifyContent: "center" },
54
59
  field: { flex: 1, flexDirection: "row", alignItems: "center", gap: 8 },
55
60
  grow: { flex: 1 },
56
61
  // Borderless, matching an inline title's resting inset so the invitation
@@ -4,11 +4,26 @@ import { colors } from "./colors";
4
4
  import { FocusRingPressable } from "./focus_ring_pressable";
5
5
  import { Icon } from "./icon";
6
6
 
7
+ /**
8
+ * Three positions, not two. `partial` is the state a BINARY ring has to lie about — a step
9
+ * whose vocabulary reads "No portal account → Account created → Filed online" is not finished
10
+ * at "Account created", but it is emphatically not untouched either, and an empty ring says
11
+ * untouched. A column of rings is the fastest scan on a task surface, so the one row shape it
12
+ * reports wrongly is the one the reader most needs to find.
13
+ */
14
+ export type CheckCircleState = "none" | "partial" | "done";
15
+
7
16
  export interface CheckCircleProps {
8
- checked: boolean;
9
- /** Toggle handler. Omit for a read-only ring (shows state, not pressable). */
10
- onChange?: (checked: boolean) => void;
11
- /** Fill colour when checked. Default zinc-900 (pass a list/project colour to tint it). */
17
+ state: CheckCircleState;
18
+ /**
19
+ * Completion handler `true` to finish, `false` to reopen. Binary ON PURPOSE even though
20
+ * the ring shows three: the ring's meaning has to stay constant ("is this finished") or a
21
+ * click becomes unpredictable. `partial` is reached through whatever NAMES it — a status
22
+ * cell in the step's own words, or children ticking off — never by cycling this control.
23
+ * Omit for a read-only ring (shows state, not pressable).
24
+ */
25
+ onChange?: (done: boolean) => void;
26
+ /** Fill colour when done (and the half-fill when partial). Default zinc-900. */
12
27
  color?: string;
13
28
  /** Diameter. Default 20. */
14
29
  size?: number;
@@ -17,22 +32,43 @@ export interface CheckCircleProps {
17
32
  }
18
33
 
19
34
  /**
20
- * A circular completion toggle — an empty ring that fills with a spring + a white
21
- * check when checked (the iOS-Reminders / Linear "done" gesture), distinct from
22
- * the square form `Checkbox`. The signature control for a task / to-do / checklist
23
- * item; pair it with a title that strikes + mutes on the same `checked`. It stops
24
- * the press, so it works inside a pressable row. Read-only (no `onChange`) shows
25
- * the state without a tap.
35
+ * A circular completion control — an empty ring that fills with a spring + a white check when
36
+ * done (the iOS-Reminders / Linear gesture), distinct from the square form `Checkbox`. The
37
+ * signature control for a task / to-do / checklist item; pair it with a title that strikes +
38
+ * mutes on the same `done`. It stops the press, so it works inside a pressable row. Read-only
39
+ * (no `onChange`) shows the state without a tap.
40
+ *
41
+ * `partial` half-fills it. Deliberately MONOCHROME at every position: a task row that also
42
+ * carries a coloured status would otherwise double-code the same fact, and the ring's job is
43
+ * "how far", not "which state".
26
44
  */
27
45
  export function CheckCircle(props: CheckCircleProps) {
28
- const { checked, onChange, color = colors.zinc[900], size = 20, disabled, accessibilityLabel } = props;
29
- const v = useRef(new Animated.Value(checked ? 1 : 0)).current;
46
+ const { state, onChange, color = colors.zinc[900], size = 20, disabled, accessibilityLabel } = props;
47
+ const done = state === "done";
48
+ const v = useRef(new Animated.Value(done ? 1 : 0)).current;
30
49
  useEffect(() => {
31
- Animated.spring(v, { toValue: checked ? 1 : 0, useNativeDriver: false, friction: 6, tension: 160 }).start();
32
- }, [checked, v]);
50
+ Animated.spring(v, { toValue: done ? 1 : 0, useNativeDriver: false, friction: 6, tension: 160 }).start();
51
+ }, [done, v]);
33
52
 
53
+ // Sits INSIDE the 1.5px border so the outline stays whole around it — a filled half in a
54
+ // complete ring reads as progress, where a half-covered ring would read as a rendering bug.
55
+ const half = (size - 3) / 2;
34
56
  const face = (borderColor: string) => (
35
57
  <View style={[styles.ring, { width: size, height: size, borderRadius: size / 2, borderColor }]}>
58
+ {state === "partial" ? (
59
+ <View
60
+ style={{
61
+ position: "absolute",
62
+ left: 0,
63
+ top: 0,
64
+ width: half,
65
+ height: size - 3,
66
+ borderTopLeftRadius: half,
67
+ borderBottomLeftRadius: half,
68
+ backgroundColor: color,
69
+ }}
70
+ />
71
+ ) : null}
36
72
  <Animated.View style={{ position: "absolute", width: size, height: size, borderRadius: size / 2, backgroundColor: color, transform: [{ scale: v }], opacity: v }} />
37
73
  <Animated.View style={{ opacity: v, transform: [{ scale: v }] }}>
38
74
  <Icon name="check" size={Math.round(size * 0.62)} color={colors.white} />
@@ -40,22 +76,30 @@ export function CheckCircle(props: CheckCircleProps) {
40
76
  </View>
41
77
  );
42
78
 
79
+ // Anything past "none" is engaged, so the outline darkens — the half-fill alone was too
80
+ // quiet to catch in a column.
81
+ const resting = state === "none" ? colors.zinc[300] : color;
82
+
43
83
  if (!onChange) {
44
84
  return (
45
85
  <View accessibilityRole="image" accessibilityLabel={accessibilityLabel} style={[styles.tap, { width: size, height: size }, disabled ? styles.disabled : null]}>
46
- {face(checked ? color : colors.zinc[300])}
86
+ {face(resting)}
47
87
  </View>
48
88
  );
49
89
  }
50
90
 
51
91
  const press = (e: GestureResponderEvent) => {
52
92
  e?.stopPropagation?.();
53
- if (!disabled) onChange(!checked);
93
+ if (!disabled) onChange(!done);
54
94
  };
55
95
  return (
56
96
  <FocusRingPressable
57
97
  accessibilityRole="checkbox"
58
- accessibilityState={{ checked, disabled }}
98
+ // The W3C prop, NOT `accessibilityState` — this react-native-web build drops the latter
99
+ // silently, so every ring shipped a `role="checkbox"` that never announced whether it was
100
+ // checked. "mixed" is aria's own tri-state, which is exactly what `partial` is.
101
+ aria-checked={done ? true : state === "partial" ? "mixed" : false}
102
+ aria-disabled={disabled}
59
103
  accessibilityLabel={accessibilityLabel}
60
104
  disabled={disabled}
61
105
  onPress={press}
@@ -64,7 +108,7 @@ export function CheckCircle(props: CheckCircleProps) {
64
108
  // the keyboard ring hugs the toggle instead of boxing it.
65
109
  style={[styles.tap, { width: size, height: size, borderRadius: size / 2 }, disabled ? styles.disabled : null]}
66
110
  >
67
- {({ hovered }: { hovered?: boolean }) => face(checked ? color : hovered ? colors.zinc[500] : colors.zinc[300])}
111
+ {({ hovered }: { hovered?: boolean }) => face(state === "none" && hovered ? colors.zinc[500] : resting)}
68
112
  </FocusRingPressable>
69
113
  );
70
114
  }
package/src/checkbox.tsx CHANGED
@@ -9,6 +9,12 @@ interface CheckboxProps {
9
9
 
10
10
  export function Checkbox(props: CheckboxProps) {
11
11
  const { checked, indeterminate } = props;
12
+ // `indeterminate` FILLS the box, exactly as `checked` does — the mixed state is a painted
13
+ // box with a dash on every platform that has one, and the dash is drawn in `white`. Keying
14
+ // the fill on `checked` alone meant a mixed-but-unchecked control drew a white dash on the
15
+ // white background: invisible. Every `selectAll` therefore reported "nothing selected" while
16
+ // rows were selected — under-reporting, which is the worse direction to be wrong in.
17
+ const filled = checked || indeterminate === true;
12
18
 
13
19
  return (
14
20
  <View
@@ -19,7 +25,7 @@ export function Checkbox(props: CheckboxProps) {
19
25
  alignItems: "center",
20
26
  borderWidth: 1,
21
27
  borderColor: colors.border,
22
- backgroundColor: checked ? colors.zinc["800"] : colors.background,
28
+ backgroundColor: filled ? colors.zinc["800"] : colors.background,
23
29
  borderRadius: 6,
24
30
  }}
25
31
  >
package/src/locale.tsx CHANGED
@@ -69,18 +69,6 @@ export interface LoticsLocale {
69
69
  trendFooter: { up: string; down: string };
70
70
  /** `SuggestionChip`: the press target's default name and the ✕ tooltip. */
71
71
  suggestionChip: { add: (label: string) => string; dismiss: string };
72
- /** `ChecklistRow`'s per-row NOTE: the add affordance's name/tooltip, the note
73
- * line's press name (it WRAPS the note text so the line is announced with
74
- * it), and the editor's placeholder — plus the SUBTASK expander's name,
75
- * given the count of unchecked children. The expander's open/closed state
76
- * rides `aria-expanded`, so `subtasks` must NOT name it: one stable name,
77
- * or every toggle re-announces the control. */
78
- checklist: {
79
- addNote: string;
80
- editNote: (note: string) => string;
81
- notePlaceholder: string;
82
- subtasks: (missing: number) => string;
83
- };
84
72
  /** `Confidence`: the full level phrase ("High confidence" …). */
85
73
  confidence: ConfidenceLabels;
86
74
  /** `Finding`: the severity badge word ("Critical" …). */
@@ -188,12 +176,6 @@ export const en: LoticsLocale = {
188
176
  chip: { remove: "Remove" },
189
177
  trendFooter: { up: "Up", down: "Down" },
190
178
  suggestionChip: { add: (label) => `Add: ${label}`, dismiss: "Dismiss suggestion" },
191
- checklist: {
192
- addNote: "Add note",
193
- editNote: (note) => `Edit note: ${note}`,
194
- notePlaceholder: "Add a note…",
195
- subtasks: (missing) => (missing > 0 ? `Subtasks: ${missing} left` : "Subtasks: all done"),
196
- },
197
179
  confidence: { high: "High confidence", medium: "Medium confidence", low: "Low confidence" },
198
180
  remainderMeter: {
199
181
  applied: (allocated, total) => `${allocated} of ${total} applied`,
@@ -292,12 +274,6 @@ export const vi: LoticsLocale = {
292
274
  chip: { remove: "Xóa" },
293
275
  trendFooter: { up: "Tăng", down: "Giảm" },
294
276
  suggestionChip: { add: (label) => `Thêm: ${label}`, dismiss: "Bỏ gợi ý" },
295
- checklist: {
296
- addNote: "Thêm ghi chú",
297
- editNote: (note) => `Sửa ghi chú: ${note}`,
298
- notePlaceholder: "Thêm ghi chú…",
299
- subtasks: (missing) => (missing > 0 ? `Việc con: còn ${missing}` : "Việc con: đã xong"),
300
- },
301
277
  confidence: { high: "Độ tin cậy cao", medium: "Độ tin cậy trung bình", low: "Độ tin cậy thấp" },
302
278
  remainderMeter: {
303
279
  applied: (allocated, total) => `Đã phân bổ ${allocated}/${total}`,