Mobile & screens
Okta's identity inside a mini-app (Liquid Glass)
The mobile app has adopted Liquid Glass as its visual language: the shell — app bar, sheets, tab bar, tenant cards — is a set of translucent surfaces that pick up what is behind them and catch light along their edge. This chapter says exactly which part of that is yours, which part is not, and why.
In one line: you do not build the glass — you ask for it. The host draws it through
package:okta_glass, and it draws it with its own parts, so when Okta restyles the material your app changes with the next host release without you publishing anything.
The split: what we draw, what you draw
| Layer | Drawn by | Note |
|---|---|---|
| The purple field behind everything | host | You cannot reach it and do not need to |
| The app bar above your app | host (or OktaAppBar) |
Dark and solid in both modes — the fixed shape above |
| The permissions sheet, banners | host | They appear over your screen without asking you |
| Your app's canvas | you | In the material via OktaGlass, or solid via OktaSurface |
| Your cards, rows and buttons | you | In OktaPalette colours |
package:okta_glass — the material, ready made
import 'package:okta_glass/okta_glass.dart';
Scaffold(
backgroundColor: const Color(0x00000000),
appBar: OktaAppBar.build("Today's attendance", palette),
body: OktaGlass.background( // the field, behind the whole screen
ListView(
padding: const EdgeInsets.all(16),
children: <Widget>[
OktaGlass.card( // a card-weight sheet
OktaList.row(OktaIcons.calendar(), 'First period', '7:30', palette),
),
],
),
),
)
| Call | Weight | When |
|---|---|---|
OktaGlass.card(child) |
blur 22 · radius 20 · elevated | A card — what you want most of the time |
OktaGlass.chrome(child) |
blur 30 · radius 26 | Something that floats over content moving beneath it |
OktaGlass.field(child) |
blur 18 · radius 16 · flat | An input — it has to read as recessed |
OktaGlass.surface(r, b, e, child) |
yours | Only when you must |
OktaGlass.background(child) |
— | The field behind your body |
One field per screen, never one per card. The field is what makes every sheet agree about where the light comes from; a field per card gives each one its own sun.
Two layers maximum. A third loses the edges their contrast and everything turns to fog.
Why the host draws it and you do not
The bridge does not have the tools. Glass is five layers, and three of them
do not cross dart_eval:
BackdropFilterandImageFilter.blurare not in the bridge's vocabulary at all — no blur.BoxDecorationcarriescolorandboxShadowonly;gradientis commented out of the bridge declaration, and an undeclared named argument is silently dropped. So the raked tint compiles cleanly and never reaches Flutter — you get a flat rectangle with not one error, andvalidate.dartdoes not catch it because nothing failed.BoxDecoration(border: …)does not compile at all — no specular rim.
And that indirection is the feature, not the cost. The drawing lives in the
host's binary, not in your bytecode. So when Okta restyles the material — its
colour, its blur, its edge — the change reaches every published mini-app on
the next host release: no rebuild, no new version, not one line you edit. A
partner who hand-rolled the look from ClipRRect and ColoredBox is frozen at
the day they shipped.
package:okta_glassneedsmin_contract: 16or higher. Declare it in the manifest, or compilation fails on a device carrying an older host.And a contract's features are only real once a host build carrying them reaches the user's device. Raising
min_contractahead of that does not make your app newer — it makes it refuse to run on every device in the field until the store update lands. Check the published host contract in the partner dashboard before you depend on it.
The palette — the single source of colour
Never write a colour literal. Theme.of(context).colorScheme does not cross
the bridge, so the palette is your only route to the host's identity:
import 'package:okta_host/okta_host.dart';
import 'package:okta_kit/okta_kit.dart';
Widget main() => const HomeScreen();
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
// Build it once at the root and pass it down. Do not rebuild it per widget.
final palette = OktaPalette.of(Okta.isDark());
return Scaffold(
backgroundColor: palette.surface,
appBar: OktaAppBar.build("Today's schedule", palette),
body: /* … */,
);
}
}
| Role | Field | Use it for |
|---|---|---|
| Brand | palette.brand |
Primary buttons, the selected tab, the accent icon |
| Brand soft | palette.brandSoft |
Touch ripple, hover wash |
| On brand | palette.onBrand |
Text and icons on top of brand |
| Ground | palette.surface |
The screen background — Scaffold.backgroundColor |
| Raised surface | palette.surfaceRaised |
Cards and blocks sitting on the ground |
| Strong text | palette.textStrong |
Headings and body |
| Muted text | palette.textMuted |
Descriptions and secondary data |
| Border | palette.border |
Hairlines |
brand is not the same colour in both modes: #6D428F in light,
#A57BBA in dark. Saturated brand purple over a near-black ground glows and
hurts legibility. Read it from the palette; never hard-code it.
These values mirror
OktaTokensin the mobile app, and a test there —okta_kit_palette_parity_test.dart— fails the build if the two copies drift. What you read here is what actually runs on the device.
Recipes that give you Okta's shape despite the bridge
The route to the host's look is not copying its widgets — it is matching its numbers with the tools that work.
A rounded card. Not BoxDecoration(borderRadius:) — it is silently dropped
and paints square corners. Corners come from ClipRRect:
Widget card(OktaPalette palette, Widget child) {
return ClipRRect(
borderRadius: BorderRadius.circular(16), // radiusXl — the card radius
clipBehavior: Clip.antiAlias,
child: ColoredBox(
color: palette.surfaceRaised,
child: Padding(
padding: const EdgeInsets.all(16), // space4
child: child,
),
),
);
}
A hairline. Not Border.all — it does not compile. A hairline is a box one
pixel tall:
Container(height: 1.0, color: palette.border)
Elevation — and a trap worth knowing. boxShadow is supported, but a
shadow and a rounded corner cannot coexist: rounding works only through
ClipRRect, shadows only through BoxDecoration, and BoxDecoration carries
no borderRadius. So a shadow behind a rounded card is drawn square and
shows at every corner.
Either a flat rounded surface separated by its colour (OktaSurface.card does
this), or OktaGlass.card, which gets blur, edge and shadow at once because the
host draws it. A hand-rolled shadow is only ever right on a square-cornered box.
The primary button — built, not configured. ElevatedButton and
TextButton do not accept style on this engine; the parameter is not
declared in the bridge, so your colour is silently dropped and the button
arrives in the default grey. The button that carries your identity is
assembled:
Widget primaryButton(OktaPalette palette, String label, void Function() onTap) {
return GestureDetector(
onTap: () => onTap(), // a closure, not a function reference
child: ClipRRect(
borderRadius: BorderRadius.circular(999),
clipBehavior: Clip.antiAlias,
child: ColoredBox(
color: palette.brand,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Center(
child: Text(
label,
style: TextStyle(
color: palette.onBrand,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
),
),
),
);
}
Use ElevatedButton for a secondary action whose colour does not matter, and
this for the primary one.
Text alignment. Text does not accept textAlign here, and TextAlign is
not a bridged type at all. Alignment comes from Center or Align around the
text, not from inside it.
The numbers that make you look like us. Match them; do not invent others:
| Value | |
|---|---|
| Card radius | 16 — capsule 999 |
| Card padding | 16, compact 12 |
| Spacing rhythm | multiples of 4: 4 · 8 · 12 · 16 · 24 · 32 |
| Text sizes | 12 faint · 14 secondary · 16 body · 18 section title · 20 screen title |
| Weights | 400 body · 500 label · 600 heading — never 700 |
| Motion | 150 fast · 220 base · 320 slow |
Motion. Use package:okta_motion — its easeOutCubic is the host's own
curve, so your app does not feel faster or slower than the screen it opened
from:
import 'package:okta_motion/okta_motion.dart';
OktaMotion.entrance(index * 40, 220, card(palette, row))
entrance(delayMs, ms, child) is the same stagger the home screen's cards enter
with: 40ms between one row and the next. Cap the delay at ten rows — the tail of
a long table must not arrive two seconds late.
Icons. OktaIcons.* only. Arbitrary Icons.* constants are not bridged,
and worse, the app is built with --no-tree-shake-icons precisely because your
code points are chosen at run time: an icon outside the set can reach the device
as an empty box with no error at all.
Text direction
The host is Arabic-first. AlignmentDirectional is not bridged, so direction
comes from Directionality and OktaText:
final locale = Okta.locale();
Directionality(
textDirection: OktaText.isRtl(locale) ? TextDirection.rtl : TextDirection.ltr,
child: /* … */,
)
Text(OktaText.pick(locale, 'جدول اليوم', "Today's schedule"))
And one rule everyone forgets: identifiers, paths and version numbers stay
LTR whatever the interface language — wrap them in
Directionality(textDirection: TextDirection.ltr) or 04:A2:9F flips on screen.
Dark mode is not optional
Okta.isDark() follows the system appearance on the user's device. An
app that reads the palette once at its root gets both modes for free; an app
that writes Color(0xFF...) once is right in one mode and wrong in the other —
and that is the single most common reason a visual review is rejected.
Pre-publish checklist
- No colour literal anywhere in the code — all of them from
palette.* -
Scaffold.backgroundColor=palette.surface - Cards on
palette.surfaceRaised, corners fromClipRRectnotBoxDecoration - No
Border.all, noBoxDecoration(gradient:)— the first does not compile, the second is dropped in silence - No hand-built glass — use
OktaGlass(andmin_contract: 16) - No shadow behind a rounded corner — it draws square
- No
ElevatedButton(style:), noText(textAlign:)— both are dropped in silence - Components from
OktaButton/OktaSurface/OktaList/OktaStates, not hand-built - Icons from
OktaIconsexclusively - Opened in both light and dark, and the text read in both
- Identifiers and paths wrapped LTR
- All three states handled explicitly: empty, loading, error
A ready prompt for your coding assistant
Paste it verbatim into Claude Code, Cursor or Codex before asking for a screen:
You are building a Dart mini-app that runs inside the Okta mobile app,
compiled on the device by dart_eval. Follow this literally:
Identity:
- Every colour from OktaPalette.of(Okta.isDark()) — no literal
Color(0xFF..).
- Ground palette.surface, cards palette.surfaceRaised, text
palette.textStrong / palette.textMuted, hairlines palette.border.
- Card radius 16, capsule 999, padding 16, spacing in multiples of 4.
- Text sizes 12/14/16/18/20, weights 400/500/600 only.
- Icons from OktaIcons only.
- Components from okta_kit, never hand-built: OktaButton.primary/secondary,
OktaSurface.card/hairline/pill/iconTile, OktaList.row/sectionHeader,
OktaStates.loading/empty/failure, and the numbers from OktaMetrics.
- Motion from package:okta_motion, durations 150/220/320.
Engine limits — every one of these breaks:
- BoxDecoration carries color and boxShadow only. borderRadius and
gradient are silently dropped: round corners with ClipRRect + ColoredBox.
- BoxDecoration(border:) does not compile: a hairline is
Container(height: 1.0).
- No BackdropFilter, no ImageFilter. The material comes from
package:okta_glass: OktaGlass.card / chrome / field / background
(needs min_contract 16).
- A shadow and a rounded corner cannot coexist — use OktaGlass.card to lift.
- Buttons: ElevatedButton and TextButton only, and **neither accepts style**:
build the primary button from GestureDetector + ClipRRect +
ColoredBox(palette.brand).
- Text does not accept textAlign: align with Center or Align around it.
- Layout: Row, Column, Expanded — and always pass flex:. No Wrap, no
AlignmentDirectional.
- No nested loops at all — flatten hierarchical data into one list.
- Callbacks are closures: onPressed: () => f() not onPressed: f.
- Keep JSON dynamic end to end; never give the receiver a Map type.
- No State.mounted, and no Timer (but DateTime, Duration and Future.delayed
exist — a drain loop calls itself again after await Future.delayed(d), with
your own stop flag). Never pass a callback to Future.delayed: it compiles
and is never called.
- GestureDetector, not InkWell — and InkWell, InkResponse and SafeArea
compile and then throw UnimplementedError.
What kills the app on a user's phone — follow this literally:
- Identity through the scalars: Okta.locale() / tenantId() / roleId() /
isDark(). Okta.context() is a map with snake_case keys; .locale on it does
not exist.
- Okta.uploadFile, location and preciseLocation return maps: ['file_name'],
['latitude'] — not .fileName, not .latitude.
- Never read a possibly-absent key: make the server always emit every field
(null when empty). A missing key yields a raw null; `is` throws on it and
`== null` lies — and no containsKey guard (tried and reverted). OktaJson
protects the shape, not absence. The one test that answers absence is
identical(value, null) — ask it before calling, interpolating or storing
the value.
- setState with a block body: setState(() { x = y; }), never
setState(() => x = y).
- Never read a bool straight out of a list: flags[i] == true.
- Never initialise a loop counter from a parameter, and never pass a number
through a parameter into a bridged constructor.
- Never nest ternaries — branch and return.
- A JSON element does not survive ?: — rows.isEmpty ? null : rows[0]
unboxes it and the next OktaJson dies. Assign inside an if, or read
inside the branch.
- Before any call whose contract is above 5: guard with
Okta.contract() >= N or declare minContract. Calling above the contract
does not fail — it corrupts the interpreter for the rest of the session.
- list.sort() with no comparator throws: always pass the comparator.
Direction: Arabic-first via Directionality and OktaText.isRtl; identifiers
and paths stay LTR.
Handle all three states explicitly: empty, loading, error. Check res.ok,
and distinguish status == 0 (never reached a server) from a failed server
response.
Run
dart run tool/audit_bridge_usage.dart lib/main.dartbefore publishing — it catches silently dropped parameters and the three traps above before a user does.
The MCP server's
component_catalogtool serves the same set live from the platform — if your assistant is connected to it, ask before assuming.