All four modes in compact and expanded sizes on iOS and Android, every state, rendering modes, multilingual cases and large text. Spec: docs/design/widgets.md. All design values come from tokens.css; the frame around each widget does not.
Sample content is illustrative and not reviewed learning content.
Term and meaning in compact; term, meaning, example and translation in expanded. Tapping anywhere except the speaker opens Word details. No Next: the card changes with the prepared queue.
| Element | Tokens | Size / rule | Action |
|---|---|---|---|
| Container | widget.color.bg | padding widget.padding.compact (12) / widget.padding.expanded (16); gap widget.gap.compact (8) / widget.gap.expanded (12) | Whole widget: widgetURL vocelune://word/{routeId}/{targetSenseId} · Android: activity PendingIntent, same URI |
| Term | widget.color.fg, widget.text.term.* 22 / 28 semibold, lh widget.line-height.term | Wraps; never truncated. Before wrapping a single word, scale down to 80% at most (iOS minimumScaleFactor 0.8, Android autosize min 80%) | — |
| Meaning, translation | widget.color.fg-secondary, widget.text.body.* 15 / 17 | Explanation-language direction per field | — |
| Example | widget.color.fg, body | Expanded only; up to 2 lines, else omitted (not truncated) | — |
| Header | widget.color.fg-secondary, widget.color.icon, widget.text.meta 12 medium, widget.icon.meta 16 | One line; mode icon + mode name. Omitted in compact quiz front | — |
| Speaker | widget.color.surface-subtle, widget.color.fg, widget.action.icon 20 | 44×44 iOS / 48×48 Android; expanded only | Link vocelune://play/{routeId}/{targetSenseId}?part=word · Android: activity PendingIntent. Opens the playback screen; audio starts after the app is active |
Two short turns in compact; turns with the highlighted target, translations and Next in expanded. Bubbles are generic: speaker A on the start side, speaker B on the end side, following the interface layout direction.
| Element | Tokens | Size / rule | Action |
|---|---|---|---|
| Container | widget.color.bg | padding widget.padding.compact (12) / widget.padding.expanded (16); gap widget.gap.compact (8) / widget.gap.expanded (12) | Whole widget: widgetURL vocelune://word/{routeId}/{targetSenseId} · Android: activity PendingIntent, same URI |
| Bubble A / B | widget.color.bubble-a / widget.color.bubble-b, widget.bubble.radius 12, padding 4×8, gap widget.bubble.gap 4 | max-width 88%; A aligned start, B aligned end | — |
| Target highlight | widget.color.highlight + semibold + underline 2 | Grapheme range from content; never color alone | — |
| Turn translation | widget.color.fg-secondary, body | Expanded only; hidden (whole turn pair falls back to compact) if it does not fit | — |
| Header | widget.color.fg-secondary, widget.color.icon, widget.text.meta 12 medium, widget.icon.meta 16 | One line; mode icon + mode name. Omitted in compact quiz front | — |
| Speaker | widget.color.surface-subtle, widget.color.fg, widget.action.icon 20 | 44×44 iOS / 48×48 Android; expanded only | Link vocelune://play/{routeId}/{targetSenseId}?part=word · Android: activity PendingIntent. Opens the playback screen; audio starts after the app is active |
| Next | widget.color.accent, widget.color.on-accent, widget.text.label 15 semibold, widget.action.radius | min-height 44 / 48; hugs label; full width in compact | iOS Button(intent: WidgetNextIntent(instanceID, revision)) · Android broadcast …widget.action.NEXT (appWidgetId, revision). Runs without the app |
The expanded interactive quiz is mandatory on both platforms. The first answer is final and its feedback persists until Next. Feedback is always symbol + word + color.
| Element | Tokens | Size / rule | Action |
|---|---|---|---|
| Container | widget.color.bg | padding widget.padding.compact (12) / widget.padding.expanded (16); gap widget.gap.compact (8) / widget.gap.expanded (12) | Non-option space opens details |
| Sentence | widget.color.fg, compact body 15 / expanded widget.line-height.sentence 17 | Max 2 lines in compact, 3 in expanded; if longer, use the compact variant, then Open quiz | — |
| Gap | underline widget.gap-mark.width 2, min-width widget.gap-mark.min-width 40 | Announced as “blank” | — |
| Options | widget.color.option-border 1.5, widget.option.radius 8, widget.text.label 15 medium (17 expanded), gap widget.option.gap 8 | min-height 44 / 48. Compact: 2 options stacked, only if the exercise has 2 options that fit on one line each. Expanded: all options in one row; if any label wraps, stack (Android tall) or fall back | iOS WidgetAnswerIntent(instanceID, occurrenceID, optionID, revision) · Android …widget.action.ANSWER. First answer is final; stale revision is ignored |
| Element | Tokens | Size / rule | Action |
|---|---|---|---|
| Feedback pill | widget.color.feedback.correct-* or incorrect-*; icon check-circle-fill / x-circle-fill 20 | Symbol + word + color; persistent until Next | — |
| Filled sentence | answer in widget.color.highlight, semibold, underline | Correct answer always shown, also after a wrong answer | — |
| Second line | widget.color.fg-secondary | Correct: translation. Incorrect: “Your answer: …” (translation is in details) | — |
| Options (tall only) | correct: feedback.correct-bg/border 2 + check; chosen wrong: feedback.incorrect-bg/border 2 + cross; others dimmed widget.color.divider | Not tappable after answering | — |
| Next | widget.color.accent, widget.color.on-accent, widget.text.label 15 semibold, widget.action.radius | min-height 44 / 48; hugs label; full width in compact | iOS Button(intent: WidgetNextIntent(instanceID, revision)) · Android broadcast …widget.action.NEXT (appWidgetId, revision). Runs without the app |
Used when the options cannot be shown without truncation (three options in compact, long option labels, or large text).
| Element | Tokens | Size / rule | Action |
|---|---|---|---|
| Open quiz | primary action tokens; icon arrow-square-out (mirrors in RTL) | Replaces options; the sentence stays so the widget is still useful | Link vocelune://quiz/{occurrenceId} opens the same occurrence in the app |
Show swaps the front for the back; no animation is required (optional crossfade uses motion.reveal, none with reduced motion). Next advances without grading: widget flashcards are never graded in v1.
| Element | Tokens | Size / rule | Action |
|---|---|---|---|
| Container | widget.color.bg | padding widget.padding.compact (12) / widget.padding.expanded (16); gap widget.gap.compact (8) / widget.gap.expanded (12) | Whole widget: widgetURL vocelune://word/{routeId}/{targetSenseId} · Android: activity PendingIntent, same URI |
| Front term | widget.text.term.* 22 / 28 semibold; optional phonetics widget.color.fg-secondary | Vertically centered | — |
| Show | primary action; icon eye | min-height 44 / 48 | iOS WidgetRevealIntent(instanceID, revision) · Android …widget.action.REVEAL |
| Back | term body semibold; meaning 17 (expanded) widget.color.fg; example + translation | Compact back: term + meaning only | — |
| Speaker | widget.color.surface-subtle, widget.color.fg, widget.action.icon 20 | 44×44 iOS / 48×48 Android; expanded only | Link vocelune://play/{routeId}/{targetSenseId}?part=word · Android: activity PendingIntent. Opens the playback screen; audio starts after the app is active |
| Next | widget.color.accent, widget.color.on-accent, widget.text.label 15 semibold, widget.action.radius | min-height 44 / 48; hugs label; full width in compact | iOS Button(intent: WidgetNextIntent(instanceID, revision)) · Android broadcast …widget.action.NEXT (appWidgetId, revision). Runs without the app |
Pro-locked applies to Dialogue, Quiz and Flashcard for free users and after Pro expires: the widget downgrades to the free word display and keeps a Pro entry. Every locked action checks the cached access deadline first, even on a stale snapshot, and records nothing.
| Element | Tokens | Size / rule | Action |
|---|---|---|---|
| Content | Word mode content | Same layout as Word | Whole widget: widgetURL vocelune://word/{routeId}/{targetSenseId} · Android: activity PendingIntent, same URI |
| Pro entry | widget.color.pro-bg, widget.color.pro-fg, lock-simple; compact: “Pro” chip | Expanded: full-width row, min-height 44 / 48 | Link vocelune://pro?source=widget.{mode} opens the paywall |
| Element | Tokens | Size / rule | Action |
|---|---|---|---|
| Symbol | color.brand.symbol, widget.icon.symbol 32 | Decorative (hidden from accessibility) | — |
| Message | body semibold + secondary | Shown when the prepared queue is used up or refresh is delayed with nothing cached | widgetURL vocelune://today |
| Open app | ghost action (subtle fill, accent label) | Expanded only; compact uses the whole widget | widgetURL vocelune://today |
Before the first unlock after a restart, storage cannot be read. The widget shows a neutral brand face, no content and no actions besides opening the app; it never creates attempts.
| Element | Tokens | Size / rule | Action |
|---|---|---|---|
| Container | widget.color.bg-brand | Symbol 32 centered, color.text.on-brand; no text | widgetURL vocelune://today |
Full color is shown in every section above (light and dark). Below: iOS accented rendering (used by the tinted and clear home-screen appearances) and Android dynamic color. These are simulations built from tokens; the real colors come from the system.
.widgetAccentable()): primary actions, the filled gap and highlight, feedback pills and their symbols, the Pro entry. Default group: everything else. Fills turn into outlines (check @Environment(\.widgetRenderingMode) == .accented), so no text ever sits on an opaque tinted fill. Correct and incorrect stay distinguishable by symbol and word.GlanceTheme with the system dynamic scheme on API 31+, and a ColorProviders scheme built from the brand tokens on API 29–30. Each widget.color.* token names its Material role in $extensions. Feedback and Pro colors are fixed brand tokens inside their own containers, so their contrast never depends on the wallpaper.Sample content, for layout only; linguistic review happens in the content pipeline. Direction, line height and line breaking come from each field's language (registry script), never from the interface language.
Text views with explicit layout direction; Android: BidiFormatter.unicodeWrap). Apply typography.script.line-height-multiplier by script class. Line breaking is the platform's (ICU dictionaries for Thai, Lao, Khmer, Burmese; kinsoku for Japanese). Never slice strings to fit.Widgets follow the OS text size. Each layout declares a fallback chain and the first variant that fits without truncation is used: iOS ViewThatFits; Android chooses the layout from the widget size and Configuration.fontScale when the provider builds RemoteViews.
| Layout | Tokens | Fallback chain (first that fits) | |
|---|---|---|---|
| Quiz expanded | — | row options + header → row options without header → stacked options (Android tall) → compact variant → Open quiz | — |
| Quiz compact | — | 2 stacked options → 2 options in a row (short labels) → Open quiz | — |
| Flashcard / Word expanded | — | full → drop example translation → drop example → compact layout | — |
| Dialogue expanded | — | turns + translations → turns only → compact | — |
| Touch targets | widget.action.min-height*, widget.option.min-height* | 44×44 pt iOS, 48×48 dp Android, gap ≥ 8 between targets | — |