openuispec 0.1.24 → 0.1.27

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.
Files changed (36) hide show
  1. package/README.md +44 -2
  2. package/cli/index.ts +21 -3
  3. package/cli/init.ts +56 -17
  4. package/docs/implementation-notes.md +115 -0
  5. package/docs/release-notes-v0.1.26.md +64 -0
  6. package/docs/release-notes-v0.1.27.md +28 -0
  7. package/drift/index.ts +375 -18
  8. package/examples/todo-orbit/AGENTS.md +11 -4
  9. package/examples/todo-orbit/CLAUDE.md +11 -4
  10. package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/ui/components/CommonComponents.kt +69 -18
  11. package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/ui/screens/TasksScreen.kt +5 -0
  12. package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/ui/sheets/EditorSheets.kt +5 -2
  13. package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/res/values/strings.xml +1 -0
  14. package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/res/values-ru/strings.xml +1 -0
  15. package/examples/todo-orbit/generated/ios/Todo Orbit/Resources/en.lproj/Localizable.strings +1 -0
  16. package/examples/todo-orbit/generated/ios/Todo Orbit/Resources/ru.lproj/Localizable.strings +1 -0
  17. package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Flows/RecurringRuleSheet.swift +3 -0
  18. package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Flows/TaskEditorSheet.swift +1 -0
  19. package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Screens/SettingsView.swift +2 -0
  20. package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Screens/TasksHomeView.swift +1 -0
  21. package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Support/AppModel.swift +1 -1
  22. package/examples/todo-orbit/generated/web/Todo Orbit/src/App.tsx +59 -6
  23. package/examples/todo-orbit/generated/web/Todo Orbit/src/styles.css +40 -0
  24. package/examples/todo-orbit/openuispec/README.md +24 -131
  25. package/examples/todo-orbit/openuispec/flows/create_recurring_rule.yaml +3 -0
  26. package/examples/todo-orbit/openuispec/flows/create_task.yaml +1 -0
  27. package/examples/todo-orbit/openuispec/flows/edit_task.yaml +1 -0
  28. package/examples/todo-orbit/openuispec/locales/en.json +1 -0
  29. package/examples/todo-orbit/openuispec/locales/ru.json +1 -0
  30. package/examples/todo-orbit/openuispec/screens/task_detail.yaml +1 -0
  31. package/examples/todo-orbit/openuispec/tokens/icons.yaml +6 -0
  32. package/package.json +6 -1
  33. package/prepare/index.ts +391 -0
  34. package/schema/semantic-lint.ts +592 -0
  35. package/schema/validate.ts +8 -9
  36. package/status/index.ts +187 -0
@@ -4,8 +4,6 @@ import androidx.compose.foundation.background
4
4
  import androidx.compose.foundation.layout.Arrangement
5
5
  import androidx.compose.foundation.layout.Box
6
6
  import androidx.compose.foundation.layout.Column
7
- import androidx.compose.foundation.layout.ExperimentalLayoutApi
8
- import androidx.compose.foundation.layout.FlowRow
9
7
  import androidx.compose.foundation.layout.Row
10
8
  import androidx.compose.foundation.layout.Spacer
11
9
  import androidx.compose.foundation.layout.fillMaxWidth
@@ -13,19 +11,27 @@ import androidx.compose.foundation.layout.padding
13
11
  import androidx.compose.foundation.layout.size
14
12
  import androidx.compose.foundation.layout.width
15
13
  import androidx.compose.foundation.shape.CircleShape
16
- import androidx.compose.material.icons.Icons
17
- import androidx.compose.material.icons.outlined.Translate
18
14
  import androidx.compose.material3.Card
19
15
  import androidx.compose.material3.CardDefaults
16
+ import androidx.compose.material3.DropdownMenu
17
+ import androidx.compose.material3.DropdownMenuItem
20
18
  import androidx.compose.material3.ElevatedCard
21
- import androidx.compose.material3.FilterChip
22
- import androidx.compose.material3.Icon
19
+ import androidx.compose.material3.ExperimentalMaterial3Api
20
+ import androidx.compose.material3.ExposedDropdownMenuBox
21
+ import androidx.compose.material3.ExposedDropdownMenuDefaults
23
22
  import androidx.compose.material3.MaterialTheme
24
23
  import androidx.compose.material3.OutlinedCard
25
24
  import androidx.compose.material3.OutlinedTextField
25
+ import androidx.compose.material3.SegmentedButton
26
+ import androidx.compose.material3.SegmentedButtonDefaults
27
+ import androidx.compose.material3.SingleChoiceSegmentedButtonRow
26
28
  import androidx.compose.material3.Switch
27
29
  import androidx.compose.material3.Text
28
30
  import androidx.compose.runtime.Composable
31
+ import androidx.compose.runtime.getValue
32
+ import androidx.compose.runtime.mutableStateOf
33
+ import androidx.compose.runtime.remember
34
+ import androidx.compose.runtime.setValue
29
35
  import androidx.compose.ui.Alignment
30
36
  import androidx.compose.ui.Modifier
31
37
  import androidx.compose.ui.graphics.Color
@@ -75,9 +81,7 @@ fun LanguageSelector(current: UiLocale, onSelected: (UiLocale) -> Unit) {
75
81
  UiLocale.En.name to stringResource(R.string.settings_language_en),
76
82
  UiLocale.Ru.name to stringResource(R.string.settings_language_ru)
77
83
  ),
78
- leadingIcon = {
79
- Icon(Icons.Outlined.Translate, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
80
- },
84
+ style = EnumSelectorStyle.Segmented,
81
85
  onSelected = { selected -> onSelected(UiLocale.entries.first { it.name == selected }) }
82
86
  )
83
87
  }
@@ -91,29 +95,76 @@ fun ThemeSelector(current: ThemeMode, onSelected: (ThemeMode) -> Unit) {
91
95
  ThemeMode.Light.name to stringResource(R.string.settings_theme_light),
92
96
  ThemeMode.Dark.name to stringResource(R.string.settings_theme_dark)
93
97
  ),
98
+ style = EnumSelectorStyle.Segmented,
94
99
  onSelected = { selected -> onSelected(ThemeMode.entries.first { it.name == selected }) }
95
100
  )
96
101
  }
97
102
 
98
- @OptIn(ExperimentalLayoutApi::class)
103
+ enum class EnumSelectorStyle {
104
+ Segmented,
105
+ Dropdown
106
+ }
107
+
108
+ @OptIn(ExperimentalMaterial3Api::class)
99
109
  @Composable
100
110
  fun EnumSelector(
101
111
  title: String,
102
112
  current: String,
103
113
  options: List<Pair<String, String>>,
114
+ style: EnumSelectorStyle = EnumSelectorStyle.Segmented,
104
115
  leadingIcon: @Composable (() -> Unit)? = null,
105
116
  onSelected: (String) -> Unit
106
117
  ) {
107
118
  Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
108
119
  Text(title, style = MaterialTheme.typography.titleSmall)
109
- FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
110
- options.forEach { (value, label) ->
111
- FilterChip(
112
- selected = value == current,
113
- onClick = { onSelected(value) },
114
- label = { Text(label) },
115
- leadingIcon = if (value == current && leadingIcon != null) leadingIcon else null
116
- )
120
+ when (style) {
121
+ EnumSelectorStyle.Segmented -> {
122
+ SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
123
+ options.forEachIndexed { index, (value, label) ->
124
+ SegmentedButton(
125
+ selected = value == current,
126
+ onClick = { onSelected(value) },
127
+ shape = SegmentedButtonDefaults.itemShape(index = index, count = options.size),
128
+ label = { Text(label) }
129
+ )
130
+ }
131
+ }
132
+ }
133
+
134
+ EnumSelectorStyle.Dropdown -> {
135
+ var expanded by remember { mutableStateOf(false) }
136
+ val selectedLabel = options.firstOrNull { it.first == current }?.second.orEmpty()
137
+ ExposedDropdownMenuBox(
138
+ expanded = expanded,
139
+ onExpandedChange = { expanded = !expanded }
140
+ ) {
141
+ OutlinedTextField(
142
+ value = selectedLabel,
143
+ onValueChange = {},
144
+ modifier = Modifier
145
+ .menuAnchor()
146
+ .fillMaxWidth(),
147
+ readOnly = true,
148
+ label = { Text(title) },
149
+ leadingIcon = leadingIcon,
150
+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
151
+ shape = MaterialTheme.shapes.medium
152
+ )
153
+ DropdownMenu(
154
+ expanded = expanded,
155
+ onDismissRequest = { expanded = false }
156
+ ) {
157
+ options.forEach { (value, label) ->
158
+ DropdownMenuItem(
159
+ text = { Text(label) },
160
+ onClick = {
161
+ expanded = false
162
+ onSelected(value)
163
+ }
164
+ )
165
+ }
166
+ }
167
+ }
117
168
  }
118
169
  }
119
170
  }
@@ -235,6 +235,11 @@ fun TaskDetailPane(
235
235
  item {
236
236
  Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
237
237
  Column(modifier = Modifier.fillMaxWidth(0.72f)) {
238
+ Text(
239
+ stringResource(R.string.task_detail_title),
240
+ style = MaterialTheme.typography.labelLarge,
241
+ color = MaterialTheme.colorScheme.onSurfaceVariant
242
+ )
238
243
  Text(task.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
239
244
  Text(
240
245
  task.dueDate?.let { formatAbsolute(it, locale) } ?: stringResource(R.string.task_detail_no_due_date),
@@ -80,6 +80,7 @@ fun TaskEditorSheet(
80
80
  title = stringResource(R.string.create_task_field_priority),
81
81
  current = localDraft.priority.name,
82
82
  options = priorityOptions,
83
+ style = EnumSelectorStyle.Segmented,
83
84
  onSelected = { selected ->
84
85
  localDraft = localDraft.copy(priority = Priority.entries.first { it.name == selected })
85
86
  }
@@ -185,11 +186,11 @@ fun RecurringRuleSheet(
185
186
  title = stringResource(R.string.recurring_rule_field_cadence),
186
187
  current = draft.cadence?.name.orEmpty(),
187
188
  options = listOf(
188
- "" to "—",
189
189
  Cadence.Daily.name to dailyCadenceLabel,
190
190
  Cadence.Weekly.name to weeklyCadenceLabel,
191
191
  Cadence.Monthly.name to monthlyCadenceLabel
192
192
  ),
193
+ style = EnumSelectorStyle.Segmented,
193
194
  onSelected = {
194
195
  draft = draft.copy(
195
196
  cadence = Cadence.entries.firstOrNull { entry -> entry.name == it },
@@ -211,6 +212,7 @@ fun RecurringRuleSheet(
211
212
  title = stringResource(R.string.recurring_rule_field_weekday),
212
213
  current = draft.weekday?.name.orEmpty(),
213
214
  options = weekdayOptions,
215
+ style = EnumSelectorStyle.Dropdown,
214
216
  onSelected = { draft = draft.copy(weekday = Weekday.entries.firstOrNull { day -> day.name == it }) }
215
217
  )
216
218
  }
@@ -266,7 +268,8 @@ fun RecurringRuleSheet(
266
268
  EnumSelector(
267
269
  title = stringResource(R.string.recurring_rule_field_summary_channel),
268
270
  current = draft.summaryChannel?.name.orEmpty(),
269
- options = summaryChannelOptions,
271
+ options = summaryChannelOptions.drop(1),
272
+ style = EnumSelectorStyle.Segmented,
270
273
  onSelected = {
271
274
  draft = draft.copy(summaryChannel = SummaryChannel.entries.firstOrNull { item -> item.name == it })
272
275
  }
@@ -18,6 +18,7 @@
18
18
  <string name="home_empty_title">Nothing to do</string>
19
19
  <string name="home_empty_body">Add a task or switch filters to see more items.</string>
20
20
  <string name="task_detail_no_due_date">No deadline</string>
21
+ <string name="task_detail_title">Task details</string>
21
22
  <string name="task_detail_status">Status</string>
22
23
  <string name="task_detail_priority">Priority</string>
23
24
  <string name="task_detail_notes">Notes</string>
@@ -18,6 +18,7 @@
18
18
  <string name="home_empty_title">Список пуст</string>
19
19
  <string name="home_empty_body">Добавьте задачу или смените фильтр, чтобы увидеть элементы.</string>
20
20
  <string name="task_detail_no_due_date">Без срока</string>
21
+ <string name="task_detail_title">Детали задачи</string>
21
22
  <string name="task_detail_status">Статус</string>
22
23
  <string name="task_detail_priority">Приоритет</string>
23
24
  <string name="task_detail_notes">Заметки</string>
@@ -26,6 +26,7 @@
26
26
  "analytics.overdue_section" = "Overdue review";
27
27
  "analytics.overdue_subtitle" = "Tasks that need attention first.";
28
28
  "analytics.empty_overdue_body" = "Everything important is on track.";
29
+ "task_detail.title" = "Task details";
29
30
  "task_detail.status" = "Status";
30
31
  "task_detail.priority" = "Priority";
31
32
  "task_detail.notes" = "Notes";
@@ -26,6 +26,7 @@
26
26
  "analytics.overdue_section" = "Просроченные задачи";
27
27
  "analytics.overdue_subtitle" = "Задачи, которым нужно уделить внимание в первую очередь.";
28
28
  "analytics.empty_overdue_body" = "Все важные задачи идут по плану.";
29
+ "task_detail.title" = "Детали задачи";
29
30
  "task_detail.status" = "Статус";
30
31
  "task_detail.priority" = "Приоритет";
31
32
  "task_detail.notes" = "Заметки";
@@ -34,6 +34,7 @@ struct RecurringRuleSheet: View {
34
34
  .tag(RecurrenceCadence?.some(cadence))
35
35
  }
36
36
  }
37
+ .pickerStyle(.segmented)
37
38
  errorText("cadence")
38
39
 
39
40
  TextField(model.string("recurring_rule.field_interval"), text: $draft.interval)
@@ -47,6 +48,7 @@ struct RecurringRuleSheet: View {
47
48
  Text(model.label(for: weekday)).tag(Weekday?.some(weekday))
48
49
  }
49
50
  }
51
+ .pickerStyle(.menu)
50
52
  errorText("weekday")
51
53
  }
52
54
 
@@ -79,6 +81,7 @@ struct RecurringRuleSheet: View {
79
81
  .tag(SummaryChannel?.some(channel))
80
82
  }
81
83
  }
84
+ .pickerStyle(.segmented)
82
85
  errorText("summaryChannel")
83
86
  }
84
87
  }
@@ -27,6 +27,7 @@ struct TaskEditorSheet: View {
27
27
  Text(model.label(for: priority)).tag(priority)
28
28
  }
29
29
  }
30
+ .pickerStyle(.segmented)
30
31
  DatePicker(
31
32
  model.string(editingTaskID == nil ? "create_task.field_due_date" : "edit_task.field_due_date"),
32
33
  selection: Binding(
@@ -20,11 +20,13 @@ struct SettingsView: View {
20
20
  Text(model.string("settings.language_en")).tag(AppLocale.en)
21
21
  Text(model.string("settings.language_ru")).tag(AppLocale.ru)
22
22
  }
23
+ .pickerStyle(.segmented)
23
24
 
24
25
  Picker(model.string("settings.theme"), selection: $draft.theme) {
25
26
  Text(model.string("settings.theme_light")).tag(ThemePreference.light)
26
27
  Text(model.string("settings.theme_dark")).tag(ThemePreference.dark)
27
28
  }
29
+ .pickerStyle(.segmented)
28
30
 
29
31
  Toggle(model.string("settings.reminders"), isOn: $draft.remindersEnabled)
30
32
  Toggle(model.string("settings.daily_summary"), isOn: $draft.dailySummaryEnabled)
@@ -312,6 +312,7 @@ private struct TaskDetailPanel: View {
312
312
  }
313
313
  .padding()
314
314
  }
315
+ .navigationTitle(model.string("task_detail.title"))
315
316
  }
316
317
 
317
318
  private func stat(_ title: String, value: String) -> some View {
@@ -286,7 +286,7 @@ final class AppModel: ObservableObject {
286
286
  guard let date else { return string("task_detail.no_due_date") }
287
287
  let formatter = RelativeDateTimeFormatter()
288
288
  formatter.locale = locale
289
- formatter.unitsStyle = .full
289
+ formatter.unitsStyle = .abbreviated
290
290
  return formatter.localizedString(for: date, relativeTo: .now)
291
291
  }
292
292
 
@@ -105,6 +105,7 @@ const messages: Record<Locale, Record<string, string>> = {
105
105
  "analytics.empty_trend": "No trend data yet.",
106
106
  "analytics.empty_overdue": "No overdue tasks",
107
107
  "analytics.empty_overdue_body": "Everything important is on track.",
108
+ "task_detail.title": "Task details",
108
109
  "task_detail.status": "Status",
109
110
  "task_detail.priority": "Priority",
110
111
  "task_detail.notes": "Notes",
@@ -249,6 +250,7 @@ const messages: Record<Locale, Record<string, string>> = {
249
250
  "analytics.empty_trend": "Данные тренда пока отсутствуют.",
250
251
  "analytics.empty_overdue": "Просроченных задач нет",
251
252
  "analytics.empty_overdue_body": "Все важные задачи идут по плану.",
253
+ "task_detail.title": "Детали задачи",
252
254
  "task_detail.status": "Статус",
253
255
  "task_detail.priority": "Приоритет",
254
256
  "task_detail.notes": "Заметки",
@@ -617,6 +619,7 @@ function HomeScreen({ onOpenModal }: { onOpenModal: (modal: ModalState) => void
617
619
  const navigate = useNavigate();
618
620
  const isDesktop = useIsDesktop();
619
621
  const t = useTranslator();
622
+ useDocumentTitle(t("nav.tasks"));
620
623
  const filteredTasks = useMemo(
621
624
  () => filterTasks(tasks, activeFilter, deferredSearch),
622
625
  [activeFilter, deferredSearch, tasks]
@@ -720,7 +723,7 @@ function HomeScreen({ onOpenModal }: { onOpenModal: (modal: ModalState) => void
720
723
 
721
724
  <div className="task-copy">
722
725
  <strong>{task.title}</strong>
723
- <span>{formatRelativeDate(task.dueDate, useAppStore.getState().locale, t("task_detail.no_due_date"))}</span>
726
+ <span>{formatRelativeDate(task.dueDate, useAppStore.getState().locale, t("task_detail.no_due_date"), "short")}</span>
724
727
  </div>
725
728
 
726
729
  <span className="priority-dot" style={{ background: priorityAccent[task.priority] }} />
@@ -743,6 +746,8 @@ function HomeScreen({ onOpenModal }: { onOpenModal: (modal: ModalState) => void
743
746
  function TaskDetailRoute({ onOpenModal }: { onOpenModal: (modal: ModalState) => void }) {
744
747
  const { taskId } = useParams();
745
748
  const task = useAppStore((state) => state.tasks.find((entry) => entry.id === taskId));
749
+ const t = useTranslator();
750
+ useDocumentTitle(t("task_detail.title"));
746
751
 
747
752
  if (!task) {
748
753
  return <Navigate to="/" replace />;
@@ -840,6 +845,7 @@ function AnalyticsScreen() {
840
845
  const [period, setPeriod] = useState<Period>("week");
841
846
  const t = useTranslator();
842
847
  const locale = useAppStore((state) => state.locale);
848
+ useDocumentTitle(t("nav.analytics"));
843
849
  const overview = getAnalyticsOverview(tasks);
844
850
  const trend = getTrendSeries(tasks, period, locale);
845
851
  const overdue = getOverdueTasks(tasks);
@@ -917,6 +923,7 @@ function SettingsScreen({ onOpenModal }: { onOpenModal: (modal: ModalState) => v
917
923
  const savePreferences = useAppStore((state) => state.savePreferences);
918
924
  const pushToast = useAppStore((state) => state.pushToast);
919
925
  const t = useTranslator();
926
+ useDocumentTitle(t("nav.settings"));
920
927
  const [form, setForm] = useState(preferences);
921
928
 
922
929
  useEffect(() => {
@@ -935,7 +942,7 @@ function SettingsScreen({ onOpenModal }: { onOpenModal: (modal: ModalState) => v
935
942
 
936
943
  <div className="settings-grid">
937
944
  <section className="cut-surface form-stack">
938
- <SelectField
945
+ <SegmentedField
939
946
  label={t("settings.language")}
940
947
  value={form.locale}
941
948
  onChange={(value) => setForm((current) => ({ ...current, locale: value as Locale }))}
@@ -945,7 +952,7 @@ function SettingsScreen({ onOpenModal }: { onOpenModal: (modal: ModalState) => v
945
952
  ]}
946
953
  />
947
954
 
948
- <SelectField
955
+ <SegmentedField
949
956
  label={t("settings.theme")}
950
957
  value={form.theme}
951
958
  onChange={(value) => setForm((current) => ({ ...current, theme: value as Theme }))}
@@ -1067,7 +1074,7 @@ function TaskFormModal({
1067
1074
  onChange={setNotes}
1068
1075
  placeholder={t("create_task.field_notes_placeholder")}
1069
1076
  />
1070
- <SelectField
1077
+ <SegmentedField
1071
1078
  label={taskId ? t("edit_task.field_priority") : t("create_task.field_priority")}
1072
1079
  value={priority}
1073
1080
  onChange={(value) => setPriority(value as Priority)}
@@ -1218,7 +1225,7 @@ function RecurringRuleModal({ onClose }: { onClose: () => void }) {
1218
1225
  error={confirmTouched ? errors.confirmName : ""}
1219
1226
  />
1220
1227
 
1221
- <SelectField
1228
+ <SegmentedField
1222
1229
  label={t("recurring_rule.field_cadence")}
1223
1230
  value={draft.cadence}
1224
1231
  onChange={(value) =>
@@ -1316,7 +1323,7 @@ function RecurringRuleModal({ onClose }: { onClose: () => void }) {
1316
1323
  />
1317
1324
 
1318
1325
  {draft.enableSummary ? (
1319
- <SelectField
1326
+ <SegmentedField
1320
1327
  label={t("recurring_rule.field_summary_channel")}
1321
1328
  value={draft.summaryChannel}
1322
1329
  onChange={(value) =>
@@ -1677,6 +1684,46 @@ function SelectField({
1677
1684
  );
1678
1685
  }
1679
1686
 
1687
+ function SegmentedField({
1688
+ label,
1689
+ value,
1690
+ onChange,
1691
+ options,
1692
+ error
1693
+ }: {
1694
+ label: string;
1695
+ value: string;
1696
+ onChange: (value: string) => void;
1697
+ options: Array<{ label: string; value: string }>;
1698
+ error?: string;
1699
+ }) {
1700
+ return (
1701
+ <div className="field-block">
1702
+ <span className="field-label">{label}</span>
1703
+ <div className={`segmented-control ${error ? "error" : ""}`} role="radiogroup" aria-label={label}>
1704
+ {options
1705
+ .filter((option) => option.value !== "")
1706
+ .map((option) => {
1707
+ const selected = option.value === value;
1708
+ return (
1709
+ <button
1710
+ key={option.value}
1711
+ aria-checked={selected}
1712
+ className={`segmented-option ${selected ? "selected" : ""}`}
1713
+ onClick={() => onChange(option.value)}
1714
+ role="radio"
1715
+ type="button"
1716
+ >
1717
+ {option.label}
1718
+ </button>
1719
+ );
1720
+ })}
1721
+ </div>
1722
+ {error ? <span className="field-error">{error}</span> : null}
1723
+ </div>
1724
+ );
1725
+ }
1726
+
1680
1727
  function DateField({
1681
1728
  label,
1682
1729
  value,
@@ -1774,6 +1821,12 @@ function NavItem({
1774
1821
  );
1775
1822
  }
1776
1823
 
1824
+ function useDocumentTitle(title: string) {
1825
+ useEffect(() => {
1826
+ document.title = `${title} | Todo Orbit`;
1827
+ }, [title]);
1828
+ }
1829
+
1777
1830
  function useTranslator() {
1778
1831
  const locale = useAppStore((state) => state.locale);
1779
1832
  return (key: string, params?: Record<string, string | number>) => {
@@ -323,6 +323,46 @@ button {
323
323
  opacity: 0.5;
324
324
  }
325
325
 
326
+ .segmented-control,
327
+ .segmented-inline {
328
+ display: flex;
329
+ flex-wrap: wrap;
330
+ gap: 8px;
331
+ }
332
+
333
+ .segmented-control {
334
+ padding: 6px;
335
+ border: 1px solid var(--border);
336
+ border-radius: 20px;
337
+ background: rgba(255, 255, 255, 0.74);
338
+ }
339
+
340
+ .segmented-control.error {
341
+ border-color: rgba(220, 38, 38, 0.4);
342
+ }
343
+
344
+ .segmented-option {
345
+ appearance: none;
346
+ -webkit-appearance: none;
347
+ border: 0;
348
+ outline: none;
349
+ border-radius: 14px;
350
+ background: transparent;
351
+ color: var(--text-secondary);
352
+ font-weight: 700;
353
+ padding: 10px 14px;
354
+ transition: background 180ms ease, color 180ms ease, transform 180ms ease;
355
+ }
356
+
357
+ .segmented-option:hover {
358
+ transform: translateY(-1px);
359
+ }
360
+
361
+ .segmented-option.selected {
362
+ background: rgba(15, 118, 110, 0.12);
363
+ color: var(--text);
364
+ }
365
+
326
366
  .input-shell input,
327
367
  .input-shell select,
328
368
  .input-shell textarea {