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:

  • BackdropFilter and ImageFilter.blur are not in the bridge's vocabulary at all — no blur.
  • BoxDecoration carries color and boxShadow only; gradient is 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, and validate.dart does 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_glass needs min_contract: 16 or 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_contract ahead 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 OktaTokens in 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 from ClipRRect not BoxDecoration
  • No Border.all, no BoxDecoration(gradient:) — the first does not compile, the second is dropped in silence
  • No hand-built glass — use OktaGlass (and min_contract: 16)
  • No shadow behind a rounded corner — it draws square
  • No ElevatedButton(style:), no Text(textAlign:) — both are dropped in silence
  • Components from OktaButton/OktaSurface/OktaList/OktaStates, not hand-built
  • Icons from OktaIcons exclusively
  • 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.dart before publishing — it catches silently dropped parameters and the three traps above before a user does.

The MCP server's component_catalog tool serves the same set live from the platform — if your assistant is connected to it, ask before assuming.

View the whole guide on one page · ↧ Raw Markdown