Harlowe: thinking outside the (box:)

This topic is a repository of my coding techniques for the Harlowe story format and for Twine development in general. Currently, I’m using Harlowe 3.3.3 in the Twine 2.5.1 desktop application.

I’ll be updating this topic as I continue my journey with Harlowe.

I should mention that I come from a web development background around when the internet first started. Though lot of what is being done today is uncharted territory for me, I still find it easier to tweak Twine stories using my basic knowledge of HTML, CSS and JavaScript. Much of my approach comes from this perspective and may seem unconventional. I still use Harlowe for all the story logic and variables, but leveraging basic web technology can take your Twine stories to a new level. If you love to tinker with Twine, I think you’ll get something out of what I’m posting here.


Table of Contents :

  ► Embedding Fonts
  ► CSS Units
  ► Custom HTML & CSS
  ► Styling Harlowe
  ► Integrating JavaScript
  ► Constructing a Persistent Interface

 


Note: Don’t hesitate to comment or ask questions in this topic. I’ve structured it with that in mind. And definitely let me know If I’ve made any errors in the instructions I’ve provided. Thank you.

And thank you all for being an awesome and supportive community. You guys rock!

5 Likes

I find it interesting that so far your two main tips for using the Harlowe story format are:

  1. Reset Harlowe’s default CSS settings.
  2. Don’t use Harlowe’s built-in features to style your project.

I also found it interesting that you’re advising the use of a custom end-user defined HTML like language, instead of actual standard HTML.

I look forward to learning more tips & tricks.

3 Likes

It does seem counter-intuitive, but there is a method to the madness.

My biggest motivation is code readability. Separating CSS from story logic seems like a good practice.

I’m a fan of sharing knowledge and new ideas… and I really like this community. However, I’m rethinking how to post this sort of information. I plan on revising my first post soon so it’s less daunting. (Changing the title of the topic, for sure.) How information is presented is just as important as the content itself. I’ll have to give this some more thought, but I also had to see it in action. I’m not entirely happy with it, but I got a few more tricks up my sleeve.

It generally is, and there are ways to do that using Harlowe features, like a Named Hook combined with a related CSS rule.

2 Likes

I can see where this is going and I understand the resistance to it.

I figured out how I wanted to present this information though. I was going to use the top post as an index to the entire topic of my replies. I could then just post the links to the replies I made that introduced new concepts and provide a link back to the top post with each. However, one thing will prevent that sort of ease… I just realized that you can’t edit a post after a week or so. That’s a wrench in the works, for sure. I tried.

Edit: I just got permission to continuously edit this topic again… so it’s off to the races now. :slight_smile:

I’m going to edit this topic (focus it on the custom HTML/CSS aspect) and just present it as outside the box thinking.

▲ Return to the Table of Contents

Embedding Fonts


  • The fonts in your story give it personality.
  • Variable weight fonts are the best for controlling legibility and are much smaller in file size as it’s a dynamic font (instead of multiple stand-alone fonts).
  • Make sure the font is licensed appropriately for use in your project.
  • Sites, such as Google Fonts and Fontshare, are great places to find fonts.

Note: Notepad++ is used in this example to extract the font data for CSS with the TrueType format as the focus.

  1. ) Launch Notepad++, open a new file and paste the following template into the file:
@font-face {
    font-family: ">>>NAME<<<";
    src: url(data:font/truetype;charset=utf-8;base64,>>>BASE64<<<) format("truetype");
    font-style: normal;
}
  1. ) Find a font you want to use and open the file in Notepad++ (I chose Lora and it’s a TrueType font – Lora-VariableFont_wght.ttf).
  2. ) Select all of the font data (Ctrl+A) and use the menu options: Plugins > MIME Tools > Base64 Encode
  3. ) The data will become one long string. Select all the data and copy it.
  4. ) Go back to your file that you first created (with the CSS font template above) and select the >>>BASE64<<< portion and paste the font data over top of it. (Note: You may need to change the format("truetype") parameter if using a different format.)
  5. ) Change the >>>NAME<<< portion to whatever you want to (“Lora - Variable”, in this case).
  6. ) Save this new file as a .CSS (lora-variable.css) in the same directory as your Twine story file.

Now you can add one line of code to the top of your story style sheet in Twine…

@import "lora-variable.css";

…and reference it in your story CSS as font-family: "Lora - Variable", sans-serif; (I use sans-serif as a fallback to tell if the font has any problem rendering – Lora is a serif font.)


Tip: Some fonts look great, but don’t read well as body text. These fonts can still be used for headers, buttons or game title fonts.


Extending Embedded Fonts :

In the example above, it so happens that the Lora font has a separate file for italics (Lora-Italic-VariableFont_wght.ttf). We’ll want to do the same thing again with the italic variable font. Your .CSS font file should look like this:

@font-face {
    font-family: "Lora - Variable";
    src: url(data:font/truetype;charset=utf-8;base64,[BASE64 DATA]) format("truetype");
    font-style: normal;
}
@font-face {
    font-family: "Lora - Variable";
    src: url(data:font/truetype;charset=utf-8;base64,[BASE64 ITALIC DATA]) format("truetype");
    font-style: italic;
}

…and now when you use * , // , <i> or <em> within your passages, the italic version will display properly.

Note: The font-family value is the same for both @font-faces, but the font-style is different.

The only thing left to account for is bold font variations when using ** , '' , <b> or <strong>. This is accomplished with the following CSS in your Twine story’s style sheet…

strong, b { font-variation-settings: "wght" 700; }

Lora has a font weight ranging from 400 to 700. You can control the weight to any number within that range in multiple CSS styles. Other fonts can start much thinner and work their way to much heavier numbers.


Tip: When displaying light coloured fonts on dark backgrounds, font weight is extremely important. The darker (or more discreet) the font, the more weight it typically needs to show up clearly. With brighter fonts, less weight is required. This is why I encourage variable weight fonts for body text.


Using multiple font style and weight files (non-variable font families) :

Each weight needs it’s own font-family name, such as “Times - Regular” and “Times - Bold”. However, you can still share an italic version with the same names. When you setup your CSS you might want to do something like this…

tw-story { font-family: "Times - Regular"; }
strong, b { font-family: "Times - Bold"; }

Note: This approach is untested by myself. I use variable weight fonts or static fonts (no variations). I might test this one day and update it as necessary. If others are aware of a better strategy, let me know.


Bonus Tip: Some fonts almost look good enough to use, but appear too bunched or spread out. You still might be able to use the font by adjusting the kerning (spacing between letters). Use letter-spacing and word-spacing CSS styles to see if the text becomes easier to read. (I had to use it for the Lora font, in conjunction with font-variation-settings: "wght".)


Rationale :

  • Embedding a font ensures that it’s always available to your story.
  • Your fonts will work when a user is offline.
  • You are not limited to fonts installed on the user’s device.
  • Advanced authors can embed their own custom fonts.

▲ Return to the Table of Contents

▲ Return to the Table of Contents

CSS Units


  • Text is the primary way of delivering interactive fiction.
  • How you present your text is as important as the story itself.

Style sheets have many units at their disposal. The ones that allow the most control and flexibility are rem, px and %. In rare cases, I also recommend ch.


REM Unit

Rem is the most important unit because of how it is based off of the root font size. To use rem units effectively, set the font-size in your story’s style sheet as follows:

:root { font-size: 24px; }

This root font-size should reflect the best size for displaying the body of your story’s text.

Note: Most browsers use 16px as the default font-size.

CSS rem units are always relative to the root element, meaning 1 rem is equal to 24px in this case. I recommend using rem for margin, padding, font-size and line-height because they all relate to the chosen size of the font. Your story styles might look like:

tw-story {
	font-size: 1rem;       /* = 24px */
	line-height: 1.5rem;   /* = 36px */
	padding: 2rem 0;       /* = 48px / 0px */
}

h1 { font-size: 2rem; }

Now, if you decide to change the font-size for your story, all font sizes, padding and margins will also change relative to the font-size and little effort is required to keep it all proportional.

Note: When using 0 as a CSS value, the unit is not required.


Fun Fact: The difference between rem and em is that rem is relative to the root element, while em is relative to the parent element. What this means is when elements are nested, rem produces predictable results always based off the root font-size. However, em units can change in unpredictable ways based on the parent element that might be affected by its parent element and so on. This means that 2 em is not always twice that of the main root element, but rem will always be that. If you nest many <div> tags with font-size: 2em; styled in each, the text will grow exponentially with each nested div.


PX Unit

The px unit is best for small, fine-tuned dimensions. I primarily use px units for border-width, box-shadow / text-shadow offsets and any other effect requiring precision.

A round, bordered button style could look like:

.button {
	display: inline-block;
	background-color: teal;
	border: 2px solid darkslategrey;
	border-radius: 0.5rem;
	padding: 0.5rem 1rem;
	color: white;
	text-shadow: 2px 2px darkslategrey;
}

Now if the root font-size changes, our button will still look the way we intended it to.


Tip: Using small decimal values of rem is unpredictable. 0.05rem might look good for a border-width with your current font-size, but changing the root font-size later might cause what was being drawn at 2 pixels to draw at 1 or 3 pixels instead. This could dramatically change the way your story appears.


% Unit

Percentages are used primarily for sizing the widths of blocks and columns.

By default, Harlowe’s centering markup =><= creates a block that’s 50% of the width of the passage. To change this, you can use the following CSS style:

tw-align[style*="text-align: center"] { min-width: 100%; }

Note: Percentage widths of child elements might not work unless the parent’s width is also declared. For example, you may have to add a width to the <tw-passage> style if certain child element width percentages are not rendered properly in your passages.


Tip: Percentages can get really interesting in place of other units. For example, line-height: 150%; will be 1.5 times that of the font-size for the current element.


CH Unit

The ch unit represents the width of an average character for an element’s font. For standard Harlowe stories, this is a very nice way to ensure paragraph readability. The sweet spot for readability lies within 50 – 75 characters of width.

You can set your story’s style sheet to use:

tw-passage { width: 60ch; }

The benefit then becomes that no matter the size of your font, you will always have your story’s paragraphs displayed with word wrapping occurring in a predictable manner. If you get rid of an orphan (a single word by itself on the last line of a paragraph), this will still be true if you change the root font-size later. The width will scale accordingly.

There are other CSS units that may be useful for specific situations, but the units described above will serve you well for the majority of your Harlowe story needs.


Bonus Tip: If you find that your styles are not appearing as intended, it’s possible that Harlowe is overriding your styles. At the end of a style, you can add !important to ensure that your style never gets overridden, but it also will not be possible to change the style while the story is running either so use this technique sparingly.


Rationale :

  • Mastering only a few, yet effective, CSS units will ensure that you can more easily manage your story’s presentation.
  • Using each unit in a consistent way promotes more understandable CSS code.
  • CSS knowledge goes well beyond that of Twine stories and will give you an advantage in any web page related endeavours.

▲ Return to the Table of Contents

1 Like

▲ Return to the Table of Contents

Custom HTML & CSS

  • Taking control of the look and structure of your story early on can save a lot of potential reworking.
  • Using CSS directly in your story’s Style Sheet allows you to more easily leverage the power of CSS with responsive designs for mobile, tablet or desktop.
  • Custom HTML is more meaningful to your story and much easier to read, understand and maintain.

Reasoning :

I like readable, understandable code. I was drawn to Harlowe, not because it’s the default story format, but because I was really impressed with the syntax highlighting in the Twine editor and the sophisticated language Leon Arnott (Harlowe’s programmer) has built. However, I felt that the code to style the passage elements began to blend with the story logic code. It all looked the same, until I started using HTML and the editor gave it a distinct colour with the code syntax highlighting. It’s also just solid practice to use CSS and HTML for any web design, which is the core of Twine stories.


Custom HTML

If you were to use regular HTML to style your story elements, it might look like this:

<div class="header"> Bartholomew and the <span class="slime">Oobleck</span> </div>

…but it feels a little clunky. I then made my own <x-x> tag with custom attributes of header and slime, and it just looks cleaner:

<x-x header> Bartholomew and the <x-x slime>Oobleck</x-x>  </x-x>

Note: To conform to HTML standards, custom tags should have a hyphen in them. Just having an <x> will work in today’s browsers though (even easier to read, in my opinion), but I don’t mind the <x-x> look.

HTML fully supports custom tags and attributes. It’s just not a very common practice because of a fear that Google may not crawl and read your web site properly if it encounters tags it doesn’t understand. Fortunately, this is not a concern with Twine stories. <x-x> may not mean anything specific, but neither does <div>; you still have to dive into the CSS to understand what’s going on.


Fun Fact: Harlowe stories are generated with custom HTML tags and attributes: <tw-story>, <tw-passage>, <tw-link>, <tw-hook> and the list goes on and on… and all receive custom CSS styling without any issues.


CSS For Custom HTML

You don’t need to make any special concessions for CSS to style custom HTML tags and attributes. With the regular <div> and <span> method above, your code in the story’s Style Sheet would look like:

.header { font-size: 2rem; }
.slime { color: green; }

…with custom HTML, it would look like:

x-x[header] { font-size: 2rem; }
x-x[slime] { color: green; }

Note: I keep the custom x-x as a part of the selector in CSS in case any of my custom attributes match the name of an existing attribute for a standard or possible future HTML element. (I only want my specific tags to be styled.) I also try to use one-word attribute names for readability, but you can always use slime-colour or any other multi-word tag or attribute name.


Tip: You can nest attributes within the same tag if you want. For example, if you wanted the whole title to be green just for this one case, you could use <x-x header slime>. This strategy gives you a lot of flexibility with combining and reusing styles. Coincidentally, you can also do this with conventional HTML class names using <div class="header slime">.


Bringin’ It Together :

Always try and let CSS do as much heavy lifting as possible because it’ll keep your passage code more manageable and maintainable down the road. In the example below, I’ve added a horizontal borderline below the header text within the header’s own CSS style. I can change the style of the header in almost any capacity without having to go back and change any passage code throughout the story using that style. There is so much that CSS can do with elements that it’s not really necessary to have multiple HTML tags to construct complex styles.

The story’s Style Sheet in this example:

:root {
        font-size: 24px;   /* <== base font size for the entire story */
        --header: #777; /* <== if the r, g or b values are matched (00) then one number (0) will suffice */
        --line: #555; /* <== changing this value will change all styles using this variable */
        --decision_background_hover: #222;
}
x-x { display: inline-block; }   /* <== (!) default display type */
x-x[header] {
        display: block; padding-bottom: 2.5rem;   /* <== add a bit of space above the underline border */
        border-bottom: 2px solid var(--line); 
        text-align: center; font-size: 1.5rem; color: var(--header);
}
x-x[narration] {
        display: block; width: 100%; padding: 0 1rem;   /* <== horizontal padding to let the header underline be wider than narration text */
}
x-x[decision] {
        display: block; padding: 2rem 0; width: 100%; /* <== allows child elements to use % properly */
        border-top: 2px solid var(--line);
        border-bottom: 2px solid var(--line);
}
x-x[decision] > tw-expression > tw-link { display: inline-block; padding: 0.6rem 1rem; width: 100%; }
x-x[decision] > tw-expression > tw-link:hover { background-color: var(--decision_background_hover); }
/* ^^^ added subtle background to make the choice feel more clickable ^^^ */

…we are even identifying nested Harlowe elements within the CSS ( x-x[decision] > tw-expression > tw-link ) which allows our passage code to remain even more readable and concise:

<x-x header> \
The Sleep Inn
</x-x>

<x-x narration> \
You slowly come to, as a stout dwarf drags you out the door of the tavern. "Buy a bar, she said. Follow your dreams..." he grunts with disgust.
</x-x>

<x-x decision> \
[[ Resist him, insisting that you aren't done drinking yet. ->Resist Passage]]
[[ Yell out that you're being oppressed. ->Yell Passage]]
</x-x>

Note: I’ll explain a good practice for identifying and styling specific Harlowe elements in another post.

The approach of custom HTML and CSS is unconventional, but it does have its advantages. If you like what you see in the examples above, then it’s worth exploring for your own Harlowe stories. It’s also an independent and transferable method of styling your Twine stories as it doesn’t rely on specific story format code.


Rationale :

  • HTML and CSS is the most performant way to style and render web page elements.
  • With everything being in your story’s Style Sheet, it’s very easy to reuse and share Harlowe story templates.
  • You can repurpose code from other websites easier because you don’t have to refactor it with Harlowe syntax.

▲ Return to the Table of Contents

▲ Return to the Table of Contents

Styling Harlowe

  • The style of your story should compliment its tone and setting.
  • Your browser’s inspector is the best way to correctly identify all Harlowe elements for styling.

Note: Harlowe features a DOM View when you run Test from Twine’s build options. Not all elements are identified this way though, but the majority will be. However, it can be really disruptive to the way your story is presented (hard to navigate your story) and it does not recognize any deliberate passage HTML.


Using The Inspector :

When running your story, in the browser, pressing F12 on the keyboard will launch the inspector. Right-clicking on a page element and selecting Inspect will open the inspector and highlight that element’s specific HTML code.

It may look daunting at first, but learning to navigate the inspector will give you all the information you need to create your own styles and override any that Harlowe interferes with.


Tip: It’s best to change the inspector to use the side of the browser window to get the most vertical scrolling space available. You’ll be scrolling and expanding HTML elements quite a bit as you dive into the HTML code that Harlowe generates.


You’ll notice that Harlowe has a set structure with it’s own custom HTML tags:

<html>
	<head>
		<meta charset="utf-8">
		<meta content="width=device-width, initial-scale=1" name="viewport">
		<title>Harlowe Example</title>
		<style title=”Twine CSS”> … </style>   <== harlowe’s style sheet
		<style data-title="Story stylesheet '1'"> … </style>   <== story style sheet
	</head>
	<body>
		<tw-storydata name="Harlowe Story Title" startnode="1" creator="Twine" creator-version="2.5.1" format="Harlowe" format-version="3.3.3" ifid="..." options="" tags="" zoom="1" hidden="">
			<style role="stylesheet" id="twine-user-stylesheet" type="text/twine-css"> … </style>   <== story style sheet (duplicated above in the <head> tag)
			<script role="script" id="twine-user-script" type="text/twine-javascript"> … </script>   <== story javascript
			<tw-passagedata pid="1" name="Start" tags="" position="200,100" size="100,100"> … </tw-passagedata>   <== twine passage content (including editor passage map locations)
		</tw-storydata>
		<script title="Twine engine code" data-main="harlowe"> … </script>   <== harlowe api javascript
		<tw-story tags="">   <==  (!) gets removed and then appended to the bottom of the body tag as new passages are visited
			<tw-passage tags="">
				<tw-sidebar> … </tw-sidebar>
				<tw-include type="startup" name="Startup"> … </tw-include>   <== startup passage (only exists for the first passage shown – disappears afterwards)
				<tw-include type="header" name="Header"> … </tw-include>   <== header passage
				…   <== (!) current passage html
				<tw-include type="footer" name="Footer"> … </tw-include>   <== footer passage
			</tw-passage>
		</tw-story>
	</body>
</html>

…and Harlowe will consistently nest it’s elements as such. You’ll always find the currently displayed passage code between the <tw-include> tags for the header and footer (if they exist).

So the base elements that affect how our story appears are:

  • <html>
  • <body>
  • <tw-story>
  • <tw-passage>

It’s important to understand that certain styles are inherited by the parent element so some styles in the <body> affect <tw-story>, <tw-passage> and so on.


Tip: It’s common practice to hide the <tw-sidebar> with display: none; if you don’t want the user to backtrack and undo their choices.


Harlowe markup elements also have the ability to be styled:

  • #, ##, ######### = <h1>, <h2>, <h3><h6>
  • // = <b>
  • '' = <i>
  • ~~ = <s>
  • * = <em>
  • ** = <strong>
  • ^^ = <sup>

Another important category of elements are links. Links are generated as HTML in a variety of ways so it’s important to identify the different kinds of links in your story:

  • [[x->y]] , [[y<-x]], [[y]] = <tw-expression> + <tw-link>
  • (link:)[] = <tw-hook> + <tw-link>
  • (click:)[] = <tw-enchantment class="link enchantment-link">

Targeting Harlowe Elements for Styling :

In CSS, defining an element for styling is done through selectors. Selectors are essentially a simple language of their own. Not only can you target specific elements, but you can also apply conditions as to when they get styled. It’s incredibly useful to know how to target nested elements. Some of the basics are as follows:

:root { … }   <== root variables that are used throughout the style sheet
tw-story { … }   <== <tw-story> tag
tw-enchantment.link { … }   <== <tw-enchantment> tag with a class="link"
h1, h2, h3, h4, h5, h6 { … }   <== all 6 html headers tags in one style
tw-dialog-links tw-link { … }   <== <tw-link> tag within the <tw-dialog-links> parent tag
tw-link:hover, tw-enchantment.link:hover { … }   <==  while the cursor is over the <tw-link> tag or the <tw-enchantment class="link"> tag

…for an in depth list of all CSS selector logic, check out W3Schools’ Selector Reference page.


Tip: Targetting Harlowe specific elements requires using the browser’s inspector to properly identify the correct CSS selectors to use. Not only do you see the exact HTML element, but you see its parent elements too. Sometimes, it’s the parent element that contains the unique pattern that you want to use in your selector to style a child element.


Harlowe’s Own Styles :

Your Twine story is usually a self-contained HTML file, which means that all Harlowe CSS is located in the file. I copied the Harlowe CSS and used Notepad++ to find and replace certain patterns to restructure it all to a more readable format. (Otherwise, it’s one very long string of text.)

Here is Harlowe 3.3.3’s CSS code for standard story elements (I’ve removed the debugging and transition styles to avoid displaying too much information):

tw-dialog {
z-index: 999997;
border: #fff solid 2px;
padding: 2em;
color: #fff;
background-color: #000;
display: block
}

@media(min-width:  576px) {
tw-dialog {
max-width: 50vw
}
}

tw-dialog input[type=text] {
font-size: inherit;
width: 100%;
border: solid #fff !important
}

tw-dialog-links {
text-align: right;
display: -ms-flexbox;
display: flex;
-ms-flex-pack: end;
justify-content: flex-end
}

tw-backdrop {
z-index: 999996;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0,0,0,.8);
display: -ms-flexbox;
display: flex;
-ms-flex-align: center;
align-items: center;
-ms-flex-pack: center;
justify-content: center
}

tw-backdrop~tw-backdrop {
display: none
}

tw-link,.enchantment-link {
cursor: pointer;
color: #4169e1;
font-weight: bold;
text-decoration: none;
transition: color .2s ease-in-out
}

tw-passage [style^=color] tw-link: not(: hover),tw-passage [style*=" color"] tw-link: not(: hover),tw-passage [style^=color][hover=true] tw-link: hover,tw-passage [style*=" color"][hover=true] tw-link: hover,tw-passage [style^=color] .enchantment-link: not(: hover),tw-passage [style*=" color"] .enchantment-link: not(: hover),tw-passage [style^=color][hover=true] .enchantment-link: hover,tw-passage [style*=" color"][hover=true] .enchantment-link: hover {
color: inherit
}

tw-link: hover,.enchantment-link: hover {
color: #00bfff
}

tw-link: active,.enchantment-link: active {
color: #dd4b39
}

.visited {
color: #6941e1
}

tw-passage [style^=color] .visited: not(: hover),tw-passage [style*=" color"] .visited: not(: hover),tw-passage [style^=color][hover=true] .visited: hover,tw-passage [style*=" color"][hover=true] .visited: hover {
color: inherit
}

.visited: hover {
color: #e3e
}

tw-broken-link {
color: #933;
border-bottom: 2px solid #933;
cursor: not-allowed
}

tw-passage [style^=color] tw-broken-link: not(: hover),tw-passage [style*=" color"] tw-broken-link: not(: hover),tw-passage [style^=color][hover=true] tw-broken-link: hover,tw-passage [style*=" color"][hover=true] tw-broken-link: hover {
color: inherit
}

tw-link.enchantment-mouseover,.link.enchantment-mouseover,tw-expression.enchantment-mouseover>tw-link {
color: inherit;
font-weight: inherit;
transition: none;
cursor: inherit;
border-bottom: 2px dashed #999
}

tw-link.enchantment-mouseover: hover,tw-link.enchantment-mouseover: active,.link.enchantment-mouseover: hover,.link.enchantment-mouseover: active,tw-expression.enchantment-mouseover>tw-link: hover,tw-expression.enchantment-mouseover>tw-link: active {
color: inherit
}

tw-link.enchantment-mouseover.enchantment-button,.link.enchantment-mouseover.enchantment-button,tw-expression.enchantment-mouseover>tw-link.enchantment-button {
border-style: dashed
}

tw-link.enchantment-mouseout,.link.enchantment-mouseout,tw-expression.enchantment-mouseout>tw-link {
color: inherit;
font-weight: inherit;
transition: none;
cursor: inherit;
border: rgba(64,149,191,.6) 1px solid;
border-radius: .2em
}

tw-link.enchantment-mouseout: hover,tw-link.enchantment-mouseout: active,.link.enchantment-mouseout: hover,.link.enchantment-mouseout: active,tw-expression.enchantment-mouseout>tw-link: hover,tw-expression.enchantment-mouseout>tw-link: active {
color: inherit
}

tw-link.enchantment-mouseout: hover,.link.enchantment-mouseout: hover,tw-expression.enchantment-mouseout>tw-link: hover {
background-color: rgba(175,197,207,.75);
border: rgba(0,0,0,0) 1px solid
}

tw-link.enchantment-dblclick,.link.enchantment-dblclick,tw-expression.enchantment-dblclick>tw-link {
color: inherit;
font-weight: inherit;
transition: none;
cursor: inherit;
cursor: pointer;
border: 2px solid #999;
border-radius: 0
}

tw-link.enchantment-dblclick: hover,tw-link.enchantment-dblclick: active,.link.enchantment-dblclick: hover,.link.enchantment-dblclick: active,tw-expression.enchantment-dblclick>tw-link: hover,tw-expression.enchantment-dblclick>tw-link: active {
color: inherit
}

tw-link.enchantment-dblclick: active,.link.enchantment-dblclick: active,tw-expression.enchantment-dblclick>tw-link: active {
background-color: #999
}

tw-link.enchantment-button,.link.enchantment-button,.enchantment-button: not(.link) tw-link,.enchantment-button: not(.link) .link {
border-radius: 16px;
border-style: solid;
border-width: 2px;
text-align: center;
padding: 0px 8px;
display: block
}

.enchantment-button {
display: block
}

.enchantment-clickblock {
cursor: pointer;
width: 100%;
height: 100%;
display: block
}

.enchantment-clickblock>: not(tw-enchantment): : after {
content: "";
width: 100%;
height: 100%;
top: 0;
left: 0;
display: block;
box-sizing: border-box;
position: absolute;
pointer-events: none;
color: rgba(65,105,225,.5);
transition: color .2s ease-in-out
}

.enchantment-clickblock>: not(tw-enchantment): hover: : after {
color: rgba(0,191,255,.5)
}

.enchantment-clickblock>: not(tw-enchantment): active: : after {
color: rgba(222,78,59,.5)
}

.enchantment-clickblock>: not(tw-enchantment): : after {
box-shadow: inset 0 0 0 .5vmax
}

.enchantment-clickblock>tw-passage: : after,.enchantment-clickblock>tw-sidebar: : after {
box-shadow: 0 0 0 .5vmax
}

.enchantment-mouseoverblock>: not(tw-enchantment): : after {
content: "";
width: 100%;
height: 100%;
top: 0;
left: 0;
display: block;
box-sizing: border-box;
position: absolute;
pointer-events: none;
border: 2px dashed #999
}

.enchantment-mouseoutblock>: not(tw-enchantment): : after {
content: "";
width: 100%;
height: 100%;
top: 0;
left: 0;
display: block;
box-sizing: border-box;
position: absolute;
pointer-events: none;
border: rgba(64,149,191,.6) 2px solid
}

.enchantment-mouseoutblock: hover>: not(tw-enchantment): : after {
content: "";
width: 100%;
height: 100%;
top: 0;
left: 0;
display: block;
box-sizing: border-box;
position: absolute;
pointer-events: none;
background-color: rgba(175,197,207,.75);
border: rgba(0,0,0,0) 2px solid;
border-radius: .2em
}

.enchantment-dblclickblock>: not(tw-enchantment): : after {
content: "";
width: 100%;
height: 100%;
top: 0;
left: 0;
display: block;
box-sizing: border-box;
position: absolute;
pointer-events: none;
cursor: pointer;
border: 2px solid #999
}

tw-dialog-links {
padding-top: 1.5em
}

tw-dialog-links tw-link {
border-radius: 16px;
border-style: solid;
border-width: 2px;
text-align: center;
padding: 0px 8px;
display: block;
display: inline-block
}

html {
margin: 0;
height: 100%;
overflow-x: hidden
}

*,: before,: after {
position: relative;
box-sizing: inherit
}

body {
margin: 0;
height: 100%
}

tw-storydata {
display: none
}

tw-story {
display: -ms-flexbox;
display: flex;
-ms-flex-direction: column;
flex-direction: column;
font: 100% Georgia,serif;
box-sizing: border-box;
width: 100%;
min-height: 100%;
font-size: 1.5em;
line-height: 1.5em;
padding: 5% 5%;
overflow: hidden;
background-color: #000;
color: #fff
}

tw-story [style*=content-box] * {
box-sizing: border-box
}

@media(min-width:  576px) {
tw-story {
padding: 5% 20%
}
}

tw-story tw-consecutive-br {
display: block;
height: 1.6ex;
visibility: hidden
}

tw-story select {
background-color: rgba(0,0,0,0);
font: inherit;
border-style: solid;
padding: 2px
}

tw-story select: not([disabled]) {
color: inherit
}

tw-story textarea {
resize: none;
background-color: rgba(0,0,0,0);
font: inherit;
color: inherit;
border-style: none;
padding: 2px
}

tw-story input[type=text] {
background-color: rgba(0,0,0,0);
font: inherit;
color: inherit;
border-style: none
}

tw-story input[type=checkbox] {
transform: scale(1.5);
margin: 0 .5em .5em .5em;
vertical-align: middle
}

tw-story tw-noscript {
animation: appear .8s
}

tw-passage {
display: block
}

tw-sidebar {
text-align: center;
display: -ms-flexbox;
display: flex;
-ms-flex-pack: justify;
justify-content: space-between
}

@media(min-width:  576px) {
tw-sidebar {
left: -5em;
width: 3em;
position: absolute;
-ms-flex-direction: column;
flex-direction: column
}
tw-enchantment[style*=width]>tw-sidebar {
width: inherit
}
}

tw-icon {
display: inline-block;
margin: .5em 0;
font-size: 66px;
font-family: "Verdana",sans-serif
}

tw-icon[alt] {
opacity: .2;
cursor: pointer
}

tw-icon[alt]: hover {
opacity: .4
}

tw-icon[data-label]: : after {
font-weight: bold;
content: attr(data-label);
font-size: 20px;
bottom: -20px;
left: -50%;
white-space: nowrap
}

tw-meter {
display: block
}

tw-hook: empty,tw-expression: empty {
display: none
}

tw-error {
display: inline-block;
border-radius: .2em;
padding: .2em;
font-size: 1rem;
cursor: help;
white-space: pre-wrap
}

tw-error.error {
background-color: rgba(223,58,190,.6);
color: #fff
}

tw-error.warning {
background-color: rgba(223,140,58,.6);
color: #fff;
display: none
}

.debug-mode tw-error.warning {
display: inline
}

tw-error-explanation {
display: block;
font-size: .8rem;
line-height: 1rem
}

tw-open-button,tw-folddown {
cursor: pointer;
line-height: 0em;
border-radius: 4px;
border: 1px solid rgba(255,255,255,.5);
font-size: .8rem;
margin: 0 .4rem;
padding: 3px;
white-space: pre
}

tw-folddown: : after {
content: "▶"
}

tw-folddown.open: : after {
content: "▼"
}

tw-open-button[replay] {
display: none
}

tw-error tw-open-button,tw-eval-replay tw-open-button {
display: inline !important
}

tw-open-button: : after {
content: attr(label)
}

tw-notifier {
border-radius: .2em;
padding: .2em;
font-size: 1rem;
background-color: rgba(223,182,58,.4);
display: none
}

.debug-mode tw-notifier {
display: inline
}

tw-notifier: : before {
content: attr(message)
}

tw-colour {
border: 1px solid #000;
display: inline-block;
width: 1em;
height: 1em
}

tw-enchantment: empty {
display: none
}

h1 {
font-size: 3em
}

h2 {
font-size: 2.25em
}

h3 {
font-size: 1.75em
}

h1,h2,h3,h4,h5,h6 {
line-height: 1em;
margin: .3em 0 .6em 0
}

pre {
font-size: 1rem;
line-height: initial
}

small {
font-size: 70%
}

big {
font-size: 120%
}

mark {
color: rgba(0,0,0,.6);
background-color: #ff9
}

ins {
color: rgba(0,0,0,.6);
background-color: rgba(255,242,204,.5);
border-radius: .5em;
box-shadow: 0em 0em .2em #ffe699;
text-decoration: none
}

center {
text-align: center;
margin: 0 auto;
width: 60%
}

blink {
text-decoration: none;
animation: fade-in-out 1s steps(1, end) infinite alternate
}

tw-align {
display: block
}

tw-columns {
display: -ms-flexbox;
display: flex;
-ms-flex-direction: row;
flex-direction: row;
-ms-flex-pack: justify;
justify-content: space-between
}

…this Harlowe CSS may prove useful for referencing, but remember that all this information can be obtained through the browser’s inspector. The inspector also illustrates inheritance (parent elements controlling child elements) much more clearly.


Tip: You can even inject new (or modify existing) CSS style information in the inspector if you want to test something temporarily without having to edit/recompile your story. Specific style information can also be toggled on and off. This is especially useful to test if Harlowe is interfering with a style you want to control, as you can turn off individual styles until you find the culprit.


Rationale :

  • Writing your own CSS is good practice for separating the look of your story from the story logic itself.
  • The story’s presentation can then change without having to alter the passage code at all.
  • Fortunately, there’s not a lot of Harlowe elements that you need to style in order to customize your story.
  • Becoming adept with the inspector allows you to dissect any web page you find on the internet to see how certain styling is achieved.

Food For Thought :

Harlowe allows you to style passage elements with custom macros. This is a logical way to apply CSS to your Harlowe story. However, to use macros effectively throughout your story, they should be assigned to story variables $ and these are saved in game save slots. If your project, gets updated to a new version, previous save game data might have incorrect macro styling data and you’ll need to account for that when loading older save game versions. Another concern is that, with the act of saving all styling macros, save game data may get quite large depending on the complexity of your story. Perhaps in the future, Harlowe will distinguish between global variables (story variables that are ignored when saving) and regular story variables. However, by using CSS directly on HTML elements (like what is being proposed here), this is not a concern.

▲ Return to the Table of Contents

▲ Return to the Table of Contents

Integrating JavaScript

Harlowe and custom JavaScript can work hand in hand. It’s not as robust as what SugarCube offers, but it also doesn’t have to be. Here’s my strategy for Harlowe to communicate with custom JavaScript code and vice versa.

Note: It doesn’t use hacks or do anything that is unintentional with the Harlowe story format.


The Key Element

First, we are going to examine what Harlowe purposefully does with <script> tags. This will be the way in which we bridge the two worlds. In the passage code, see how the following works:

(set: _temporary_variable to 0)
<script> _temporary_variable = 10; </script>
(print: _temporary_variable)   <!-- <== output is 10 -->

…JavaScript has changed the value of the Harlowe _temporary_variable to equal 10 and Harlowe has printed out the value in the passage successfully. This is the way JavaScript changes Twine variables in passages. This is the key to JavaScript working with Harlowe.

Note: This works for both story variables $ and temporary variables _ and is documented in the Harlowe Manual. Essentially, Harlowe variables in <script> tags automatically set or get the Harlowe variable data behind the scenes.


2-Way Communication

Next we are going to change a JavaScript variable using Harlowe. In the story JavaScript, the following is used to create our own API of sorts:

if (typeof custom_api == "undefined") {   // <== optional condition (useful if the possibility of initializing this api more than once exists)
	let data_variable = 0;   // <== global api variable
	let custom_api = {
		get_data: function() {
			return data_variable;	
		},
		set_data: function( value ) {
			data_variable = value;
		},   // <== keep a dangling comma in case one is forgotten when adding new functions (combat headaches before they happen)
	};
	window.custom_api = custom_api;   // <== assign to window scope for access anywhere
}

…and in the passage, we can use:

(set: _inserted_value to 5)
(set: _extracted_value to 0)
<script>
	custom_api.set_data( _inserted_value );
	_extracted_value = custom_api.get_data();
</script>
(print: _extracted_value)   <!-- <== output is 5 -->

…and we now have an output of 5 in our story. We used Harlowe variables to work with functions in our own custom JavaScript code, demonstrated by changing a JavaScript variable and extracting that value.

On the surface, this does nothing of real value, but it is the foundation on which to build upon.


Triggering / Targeting Output

So far, this code execution is happening as the story is being displayed and then it never runs again. However, many stories require live feedback dependent on user interaction or timers.

Because our stories work through Harlowe first, it’s very easy to run a JavaScript function from a Harlowe link and display it within the story passage, for example:

|dicehook>[ Click the link below to roll the dice... ]
(link-rerun: "Roll Dice")[ <script> roll_dice( "dicehook", 3 ); </script> ]

…and in your story JavaScript, you can have the following code:

function get_random_integer( min, max ) {   // <== this is available anywhere in subsequent code
	min = Math.ceil( min );
	max = Math.floor( max + 1 );
	return Math.floor( Math.random() * ( max - min ) + min );
}
window.roll_dice = function( hook_name, number_of_dice ) {
	hook_name = hook_name.toLowerCase();   // <== (!) lowercase precaution (harlowe changes hook names to lower case in html)
  	hook_name = hook_name.replace(/_/g, "");   // <== (!) _ precaution (harlowe removes _ from html hook names)
	let hook_count = document.getElementsByName( hook_name ).length;
    if ( hook_count == 0 ) return 0;   // <== escape this function (when no hook is found)
	let total_roll_value = 0;
	let roll_html = '<div style="display: block;"> You rolled a… ';   // <== container element
  	for (let index = 1; index <= number_of_dice; index++ ) {
		let die_value = get_random_integer( 1, 6 );   // <== calls a custom global function (see above)
		roll_html += '<div style="display: inline-block; position: relative; \
		top:-0.1em; width:1.85em; height: 1.85em; text-align: center; border: 2px solid white;">' + die_value + '</div> ';   // <== die element
		total_roll_value += die_value;
	}
	roll_html += ' …totaling ' + total_roll_value + '. </div>';   // <== closes container element
  	let hook = document.getElementsByName( hook_name )[ hook_count - 1 ];   // <== target last matching hook found (in case multiple exist with the same name)
	hook.innerHTML = "";   // <== clear hook of existing html (user can roll again repeatedly)
	hook.insertAdjacentHTML( "beforeend", roll_html );   // <== forces inserted html to exist as proper dom objects
};

…and now when we click the Roll Dice link in Harlowe, it runs our JavaScript function, which outputs HTML into the desired Harlowe target hook. The output will look somewhat like, You rolled a… [3] [5] [2] ...totaling 10. This still doesn’t accomplish anything that we can’t do in Harlowe itself, but this knowledge will eventually allow us to do things that Harlowe definitely cannot.

Note: Harlowe changes the name of hooks in the HTML that’s generated. It will change the name to lowercase letters and remove any underscores _. For compatibility and readability, I recommend using .lowercase() and the regular expression of .replace(/_/g, ""). This ensures that you don’t have to compromise the names of the hooks you use in Harlowe. Harlowe still knows them by their full names internally.


Triggering Harlowe Macros

Lastly, we want to be able to talk to Harlowe from our JavaScript functions. For brevity, I’m not going to write up another example JavaScript API, but just some direct JavaScript code to illustrate the point.

First we are going to simulate a click on a Harlowe link through JavaScript. In the Harlowe passage:

|triggerhook>[ (link-rerun: "Roll Dice")[ ( Rolled a (print: (random: 1,6)) and a (print: (random: 1,6)) ) ] ]

(link-rerun: "Simulate Click")[ <script> document.getElementsByName( "triggerhook" )[0].getElementsByTagName( "tw-link" )[0].click(); </script> ]

Note: A hook must be used to help direct the simulated click because all Harlowe <tw-link> tags don’t have any distinguishing HTML in them.

By clicking the Simulate Click link, JavasScript is firing a click event on the Harlowe <tw-link> element inside the |triggerhook>. This then runs the associated Harlowe code in that (link:) macro. This is very useful in situations where you have a JavaScript widget that needs to drive the Harlowe story further. The code in the (link:) macro can even grab other JavaScript variables and then decide the next course of story action.


Tip: You can hide the |triggerhook> (and its associated (link:) macros) to keep this method of triggering Harlowe code hidden from the user. (css: "display:none;")[ |triggerhook>[ ] ] can accomplish this. Javascript click events can still occur on hidden elements. However, you cannot use the |triggerhook)[ ] because it actually removes all the HTML inside and there is no <tw-link> to target at all.


Another way would be to create a Harlowe pseudo-listener. There is a simple delay upon changing a JavaScript variable that Harlowe will pick up on in the following passage code:

|signal_status>[ Waiting… ]
{
<script> window.js_signal = 0; </script>
(live: 0.1s)[
(set: _signal_check to 0)
<script> _signal_check = window.js_signal; </script>
(if: _signal_check is 1)[
(replace: ?signal_status)[ Got it! ]
] ]
<script> setTimeout( function() { window.js_signal = 1; }, 3000); </script>
}

…but I recommend the simulated click method because it doesn’t take up valuable CPU cycles that could possibly slow down other Harlowe event macros and the web browser in general. It’s also just more deliberate, easier to look at and understand.


Attention: In Harlowe 3.3.3, the above live code caused (click:) enchantments to flicker rapidly between regular text and a link, in the Chrome desktop browser. Clicking on the enchantment would sometimes activate the link or not, depending on the exact moment the click happened while the flickering occurred. The frequency of the (live:) macro affected the rate of flickering. With no time assigned to the (live:), the enchantment links never appeared as a link and always remained as regular text. It should also be stated that this has nothing to do with the custom JavaScript and is a bug in Harlowe. However, this illustrates how disruptive continuously running (looping) code can be and it should only be used sparingly.


Tip: You can even have persistent JavaScript elements that display outside the Harlowe <tw-story> tag. Be sure to disable your JavaScript widget’s input capabilities once the signal has been sent. The JavaScript will still be active and a second click might not do anything if the next passage has not been drawn yet. Have the passage send a signal to the JavaScript code that you’re ready for the next command. For example, this practice would work well with a navigable map that’s always beside your story. I’ll illustrate this in a subsequent post.


Conclusion :

Harlowe documentation states that it doesn’t reward authors who know JavaScript, but it does recognize the usefulness of such knowledge. By intentionally processing Harlowe variables within <script> tags, Harlowe is offering an olive branch to those who want to take their Harlowe stories further.


Rationale :

Most IF authors who try Twine for the first time, will use Harlowe. This is an excellent way to get your feet wet. Harlowe offers the most robust Twine UI integration of all the story formats (syntax highlighting, code generation shortcuts, coding tooltips, etc.). The barrier for Twine authoring is the lowest with Harlowe and the Twine editor. It’s the proper choice as Twine’s default story format. However, most will switch to SugarCube once they want to build IF that goes beyond text and graphics. I’m just showing that you don’t have to abandon what you’ve learned in Harlowe if you want to take it to the next level.

Note: There is existing JavaScript code that really opens up the guts of Harlowe, but this goes against the intent of the Harlowe story format. The maintainer of Harlowe discourages methods like this and future versions of Harlowe may even prevent that from happening (if they haven’t already). The method I propose is inline with what Harlowe offers as a way to interact with custom JavaScript. It’s does not expose the Harlowe API, nor does it disrupt Harlowe’s functionality in any way.


Food For Thought :

It’s important to consider that Twine, at it’s heart, is an interactive story creation tool. It allows you to author digital Choose Your Own Adventure books, but with dynamic sentences to read on the page. If you really want to create a Twine project that doesn’t feel like an interactive story, but more like a traditional video game, then SugarCube is most likely the better choice for your project. That said, in later posts, I’ll explain my methods for creating an interface template around the story (outside of Harlowe’s passages), creating SVG icons and graphics that are embedded in your Harlowe story (in the HTML file itself) and referenced with a single, short line of code… and other feather-ruffling stuff. :wink:

▲ Return to the Table of Contents

1 Like

note: In recent versions of Harlowe 3.x the implementation of the “timer” related macros (eg. (live:), (event:), and (after:)) was changed to use the window.requestAnimationFrame() JavaScript function instead of setTimeout(). Which means these macros are now tied to the repainting of the page, which occurs around 60 times a second, instead of being tied to a timer thread that “sleeps” for a specific period of time before “waking” to preform a task.

What affect this has to any other Document Object Model or Visual changes being applied to the page (like the Text to Link behaviour you mentioned) is unknown.

2 Likes

That’s actually very interesting. Thanks, Greyelf.

The requestAnimationFrame() is mostly used by people who make JavaScript games with the HTML canvas object, from my understanding, and need frame perfect calculations. I wonder what the thought process was with Harlowe though.

I was thinking about going to the Harlowe issues site and bringing some attention to things, but for now I’ll keep plugging away with the status quo. I’m still a newb at Harlowe and Twine, to be completely honest. Ignorance is bliss! :wink:

However, I’m more amazed that someone actually read my post. :slight_smile: I figured I was the only one.

I’m guessing, but possibly the following behaviour of the function…

requestAnimationFrame() calls are paused in most browsers when running in background tabs or hidden <iframe>s in order to improve performance and battery life.

1 Like

▲ Return to the Table of Contents

Constructing a Persistent Interface

  • An interface outside of the Harlowe story allows you to display information across visited passages.
  • Passages can then transition, while the interface remains unaffected.

The Largest Hurdle

Harlowe’s basic HTML structure in the <body> tag looks like this:

<body>
	<tw-storydata>
		<style role="stylesheet"> ... </style>
		<script role="script"> ... </script>
		<tw-passagedata> ... </tw-passagedata>
	</tw-storydata>
	<script data-main="harlowe"> ... </script>
	<tw-story tags="">
		<tw-passage tags="">
			<tw-sidebar> ... </tw-sidebar>
			<tw-include type="startup"> ... </tw-include>
			<tw-include type="header"> ... </tw-include>
			
			>>> CURRENT PASSAGE CONTENT <<<
			
			<tw-include type="footer"> ... </tw-include>
		</tw-passage>
	</tw-story>
</body>

What’s not evident is what happens to the HTML when a new passage is visited. Upon displaying a new passage, the <tw-story> tag is completely removed and reinserted at the bottom of all content within the <body> tag.

Luckily, CSS has a perfect solution for this situation buried within it’s flexbox abilities. The following will describe how to construct a persistent flexbox template around your Harlowe story, despite the <tw-story> tag being constantly removed and reinserted.


Injecting The Template

The only way to really work outside of the <tw-story> is with JavaScript, but it’s not complicated when armed with a basic knowledge of HTML and CSS. The following is in the story’s JavaScript page:

let interface_root = document.getElementsByTagName( "body" )[0];
let interface_structure = ' \
<x-layout left id="left_pane" style="display: none;"> \
    LEFT COLUMN \
</x-layout> \
<x-layout right id="right_pane" style="display: none;"> \
	RIGHT COLUMN \
	</x-area> \
</x-layout> \
<x-layout top> \
	TOP ROW \
</x-layout> \
<x-layout bottom> \
	BOTTOM ROW \
</x-layout> \
';
interface_root.insertAdjacentHTML( "beforeend", interface_structure );

let left_pane = document.getElementById( "left_pane" );
let right_pane = document.getElementById( "right_pane" );
  
window.interface = {
	hide: function () {
		left_pane.style.display = "none";
		right_pane.style.display = "none";
	},
	show: function () {
		left_pane.style.display = "flex";
		right_pane.style.display = "flex";
	},
}

…notice that we are using custom HTML (described previously) and the column elements are set to display: none; by default. We’ll show those columns only when the story requires it. The top and bottom rows are going to be faded gradients that sit above the story passages so that the text gently fades out as the content is scrolled (see the image below). The following CSS will be in the story’s style sheet:

:root {
  	font-size: 24px; /* <== base story font size */
	--scrollbar_width: 24px;
}
body { /* <== base layout container (direct parent of <tw-story>) */
  	background-color: #000;
  	color: #fff;
	margin: 0;
  	padding: 0 2.5rem;
	display: flex;
	gap: 2.5rem;
	justify-content: center; /* <== bunch together horizontally */
  	overflow-x: hidden;
  	overflow-y: scroll; /* <== always show the scroll bar to avoid horizontal shifting between passages */
}
body::-webkit-scrollbar { width: var(--scrollbar_width); } /* <== custom css variable */
body::-webkit-scrollbar-track { background-color: #222; }
body::-webkit-scrollbar-thumb { background-color: #444; }

x-layout[left], x-layout[right] {
	position: sticky; /* <== restricts elements from scrolling with the body content */
  	top: 0;
	display: flex;
	align-items: center; /* <== center vertically */
  	text-align: center;
	width: 200px;
  	min-width: 200px;
  	z-index: 200; /* <== infront of top and bottom gradient fades */
  	box-sizing: border-box; border: 4px green solid; /* <== for demonstration purposes */	
}
x-layout[left] {
  	order: 1;
  	justify-content: flex-end; /* <== right align */
}
x-layout[right] {
  	order: 3;
  	justify-content: flex-start; /* <== left align */
}
x-layout[top], x-layout[bottom] {
	pointer-events: none; /* <== allow clicking through top and bottom gradient fades to the <tw-story> element */
	position: fixed; /* <== pulls element out of flex-box flow and locks it to the screen */
	display: flex;
  	width: calc(100% - var(--scrollbar_width)); /* <== scrollbar needs to be accounted for when <body> is set to scroll */
    align-items: center;
  	justify-content: center;
	height: 4rem;
  	z-index: 100; /* <== gradient fades infront of <tw-story> */
  	box-sizing: border-box; border: 4px orange solid; /* <== for demonstration purposes */	
}
x-layout[top] {
  	top: 0;
  	background: linear-gradient(180deg, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0) 100%);
}
x-layout[bottom] {
  	bottom: 0;
  	background: linear-gradient(0deg, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0) 100%);
}

tw-story {
  	margin: 0;	
  	padding: 4rem 0; /* <== compensate for top and bottom gradient fade heights */
  	width: 64ch;
  	min-width: 48ch;
  	display: flex;
  	height: fit-content; /* <== ensures passage content can scroll */
  	flex-direction: row;
  	align-items: center; /* <== center passage vertically */
  	justify-content: center;
  	text-align: left;
  	order: 2; /* <== left pane = 1, <tw-story> = 2, right pane = 3 */
  	/* (!) disable the following default harlowe styles (inherit from <body>): */
  	font: inherit; font-size: inherit; line-height: inherit; background-color: inherit; color: inherit;
  	box-sizing: border-box; border: 4px cyan solid; /* <== for demonstration purposes */
}
tw-passage { width: 100%; } /* <== allow passage content percentages to take up the entire <tw-story> width */
tw-sidebar { display: none; } /* <== hide the harlowe navigation side bar */

Note: The order style ensures that all flexbox <body> children appear in the desired order regardless of the order they appear in the HTML page. Remember, the <tw-story> tag and all it’s contents are removed and re-appended with each visited passage. Despite this, our template remains properly structured and doesn’t flicker/redraw. Flexbox is built for being modified live and it’s the nature of responsive web design.

You might notice that all of our interface panes do not scroll with the story content. This is to ensure that our interface remains on the screen at all times, but has the benefit of keeping the passage scroll bar to the far right of the browser window, like any other Twine story. I find this preferable to having a scrollbar inside the panes of the interface template as it allows the story content to breathe and there’s much less distraction happening.


Tip: By default, all child elements of a flexbox become a part of the flow. However, there is a way to take an element out of the flow and deliberately position it on the page by using display: fixed;. This allows us to keep the top and bottom panes fixed to their corresponding positions and keep the flexbox to a simple 3-column layout.


And lastly, it’s probably better to only hide the interface on passages that are not a part of the main story. I suggest the following in the footer passage:

{
(if: (passage:)'s tags contains "no_interface")[
	<script> interface.hide(); </script>
](else:)[
	<script> interface.show(); </script>
]

<script>
	let scrollbar_width = document.body.offsetWidth - document.body.clientWidth;
	document.querySelector(':root').style.setProperty('--scrollbar_width', scrollbar_width + 'px');
</script>
}

…now, if you put a tag called no_interface in a passage, then the columns will be hidden. Otherwise, the columns will be visible. It’s easier to manage it by the passages that don’t require the interface when authoring large stories.

Also, there’s no way for CSS to know the actual width of the scrollbar, if the browser doesn’t support setting a width to it. We need the scrollbar width to calculate the width of the fixed top and bottom rows of our interface template. Fortunately, we can use JavaScript to calculate the scrollbar width and override the custom CSS variable --scrollbar_width in the :root. (Microsoft Edge and Google Chrome support specific scroll bar widths, but Mozilla Firefox does not.)


Tip: By overriding CSS :root variables with JavaScript, we essentially get to change the style sheet programmatically and the changes update all affected elements automatically throughout the entire story immediately. This has huge benefits because we don’t have to target individual HTML elements or CSS selector styles one at a time. This is how one might go about changing a story’s entire the colour scheme for colourblind users, for example, or allow the user to customize their own story theme.


Updating Template Information:

Because we’ve setup our interface template outside of Harlowe’s code base, we have to do a bit of heavy lifting to have it update properly. However, it’s not as hard as one might think. In the footer of each passage, I suggest calling a custom JavaScript function that passes on the relevant Harlowe variables. That way these variables can change in the middle of the passage’s code, but because the interface template updating is done in the footer, the JavaScript function receives the proper Harlowe values after the passage logic has concluded.

To illustrate this in a simple manner, your JavaScript window.interface object should include something like this:

update_left: function ( value ) {
	left_pane.innerHTML = value;
},
update_right: function ( value ) {
	right_pane.innerHTML = value;
},

…and in your passages, you could create variables such as:

(set: $life to 4)
(set: $max_life to 5)
(set: $gold to 3)

…and in the footer, you would update the template content with:

(if: (passage:)'s tags does not contain "no_interface")[
	<script> 
		interface.update_left( "Life: " + $life + "/" + $max_life );
    	interface.update_right( "Gold: " + $gold );
	</script>
]

…then you would get Life: 4/5 in the left column and Gold: 3 in the right column.

Note: It’s also possible to control the content in the interface template by copying the innerHTML from a hidden Harlowe hook. You can do as much or as little as you want in JavaScript. I prefer using my own HTML because I like the control it offers and, by building it myself, I understand it more.


Food For Thought: Having an interface template surrounding your story might make it tempting to use the template to control the story (as opposed to simply providing more information about the story). I do caution against creating an interactive template because, not only does it create more layers of coding complexity, it also goes against the nature of how Twine stories are intended to function and how they fundamentally work. Use custom interactive interfaces purposefully and sparingly. This is only a suggestion though.


Rationale :

An interface allows you to present persistent story information to the user at a glance. However, by only using Harlowe code to do so, the interface would have to disappear and reappear with each new passage (or you’d have to build a story that never leaves the passage and negates the built-in story history feature). Having story text disappear and reappear with new content makes perfect sense, but having the interface surrounding the story do the same is not usually desirable.

With stories that feature game mechanics, interfaces are very useful. As a narrative battle unfolds across multiple passages, the health bar can remain. As a dungeon is explored further, a persistent map can show the player’s location. An interface can even be just decorative to give your story a unique visual appeal. The interface method described here is a great tool for any Harlowe author’s arsenal. Someone might even think your Harlowe story was written with SugarCube. :wink:

▲ Return to the Table of Contents

1 Like